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
60 changes: 48 additions & 12 deletions pkgs/sdk/server/src/Internal/Evaluation/Evaluator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,12 @@ internal struct EvalState
internal ImmutableList<PrerequisiteEvalRecord>.Builder PrereqEvals;
internal Dictionary<string, IMembership> BigSegmentsMembership;
internal BigSegmentsStatus? BigSegmentsStatus;
// True once the current evaluation scope has read a definition that carries the override
// marker. Evaluate sets it from the evaluated flag. MatchClause sets it for each segment
// that is read. CheckPrerequisites starts a nested scope from the prerequisite's own
// marker and merges the nested result into this scope when it returns, so the marking
// propagates upward only.
internal bool OverrideAffected;

internal EvalState(Context context)
{
Expand All @@ -57,6 +63,7 @@ internal EvalState(Context context)
PrereqEvals = null;
BigSegmentsMembership = null;
BigSegmentsStatus = null;
OverrideAffected = false;
}
}

Expand Down Expand Up @@ -91,39 +98,46 @@ public EvalResult Evaluate(in FeatureFlag flag, in Context context)
{
throw new Exception(ErrorMessageForTesting);
}

// Reading the flag's own definition is the first read of this evaluation, so the marking
// starts from the flag's override marker.
var state = new EvalState(context);
state.OverrideAffected = flag.IsOverride;

if (!context.Valid)
{
Logger.Warn("Tried to evaluate flag with invalid context: {0} returning null",
flag.Key);

return new EvalResult(
new EvaluationDetail<LdValue>(LdValue.Null, null, EvaluationReason.ErrorReason(EvaluationErrorKind.UserNotSpecified)),
return new EvalResult(ErrorResult(EvaluationErrorKind.UserNotSpecified, state.OverrideAffected),
ImmutableList.Create<PrerequisiteEvalRecord>());
}

try
{
var state = new EvalState(context);
var details = EvaluateInternal(ref state, flag);
var reason = details.Reason;
if (state.BigSegmentsStatus.HasValue)
{
details = new EvaluationDetail<LdValue>(
details.Value,
details.VariationIndex,
details.Reason.WithBigSegmentsStatus(state.BigSegmentsStatus.Value)
);
reason = reason.WithBigSegmentsStatus(state.BigSegmentsStatus.Value);
}
// Error reasons are marked too. A malformed override definition yields the caller's
// default value with an error reason, and an override still affected that result.
reason = reason.WithOverrideAffected(state.OverrideAffected);
details = new EvaluationDetail<LdValue>(details.Value, details.VariationIndex, reason);
return new EvalResult(details, state.PrereqEvals is null ?
ImmutableList.Create<PrerequisiteEvalRecord>() : state.PrereqEvals.ToImmutable());
}
catch (StopEvaluationException e)
{
Logger.Error(@"Could not evaluate flag ""{0}"": {1}", flag.Key, string.Format(e.MessageFormat, e.MessageParams));
return new EvalResult(ErrorResult(e.ErrorKind), ImmutableList.Create<PrerequisiteEvalRecord>());
return new EvalResult(ErrorResult(e.ErrorKind, state.OverrideAffected),
ImmutableList.Create<PrerequisiteEvalRecord>());
}
catch (Exception)
{
return new EvalResult(ErrorResult(EvaluationErrorKind.Exception), ImmutableList.Create<PrerequisiteEvalRecord>());
return new EvalResult(ErrorResult(EvaluationErrorKind.Exception, state.OverrideAffected),
ImmutableList.Create<PrerequisiteEvalRecord>());
}
}

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

private static EvaluationDetail<LdValue> ErrorResult(EvaluationErrorKind kind, bool overrideAffected) =>
new EvaluationDetail<LdValue>(LdValue.Null, null,
EvaluationReason.ErrorReason(kind).WithOverrideAffected(overrideAffected));

private EvaluationDetail<LdValue> GetVariation(FeatureFlag flag, int variation, in EvaluationReason reason)
{
if (variation < 0 || variation >= flag.Variations.Count())
Expand Down Expand Up @@ -213,7 +231,23 @@ private EvaluationDetail<LdValue> GetOffValue(FeatureFlag flag, in EvaluationRea
}
else
{
var prereqDetails = EvaluateInternal(ref state, prereqFeatureFlag);
// The nested evaluation is a scope of its own. It starts from the prerequisite's
// own marker so that its record reflects only the definitions its subtree read.
// When it returns, or throws, its marking merges into this scope. The marking
// propagates upward only.
var parentOverrideAffected = state.OverrideAffected;
state.OverrideAffected = prereqFeatureFlag.IsOverride;
EvaluationDetail<LdValue> prereqDetails;
bool prereqOverrideAffected;
try
{
prereqDetails = EvaluateInternal(ref state, prereqFeatureFlag);
}
finally
{
prereqOverrideAffected = state.OverrideAffected;
state.OverrideAffected = parentOverrideAffected || prereqOverrideAffected;
}
// 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.
Expand All @@ -225,7 +259,9 @@ private EvaluationDetail<LdValue> GetOffValue(FeatureFlag flag, in EvaluationRea
{
state.PrereqEvals = ImmutableList.CreateBuilder<PrerequisiteEvalRecord>();
}
state.PrereqEvals.Add(new PrerequisiteEvalRecord(prereqFeatureFlag, flag.Key, prereqDetails));
var prereqRecordDetails = new EvaluationDetail<LdValue>(prereqDetails.Value, prereqDetails.VariationIndex,
prereqDetails.Reason.WithOverrideAffected(prereqOverrideAffected));
state.PrereqEvals.Add(new PrerequisiteEvalRecord(prereqFeatureFlag, flag.Key, prereqRecordDetails));
}
if (!prereqOk)
{
Expand Down
14 changes: 12 additions & 2 deletions pkgs/sdk/server/src/Internal/Evaluation/EvaluatorClause.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,19 @@ private bool MatchClause(ref EvalState state, in Clause clause)
foreach (var value in clause.Values)
{
Segment segment = SegmentGetter(value.AsString);
if (segment != null && MatchSegment(ref state, segment))
if (segment != null)
{
return MaybeNegate(clause, true);
// The segment definition is read here, so an override segment marks the
// evaluation. A match is not required. A negated clause turns a non-match into
// a match, so the definition shapes the result either way.
if (segment.IsOverride)
{
state.OverrideAffected = true;
}
if (MatchSegment(ref state, segment))
{
return MaybeNegate(clause, true);
}
}
}
return MaybeNegate(clause, false);
Expand Down
20 changes: 19 additions & 1 deletion pkgs/sdk/server/src/Internal/Model/FeatureFlag.cs
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,17 @@ internal sealed class FeatureFlag : IJsonSerializable

public Migration? Migration { get; }

/// <summary>
/// True if this definition came from the SDK's override store rather than from LaunchDarkly
/// data. The evaluator reads this marker to mark the evaluation as override-affected. The
/// marker lives on the model only. It is never serialized.
/// </summary>
internal bool IsOverride { get; }

internal FeatureFlag(string key, int version, bool deleted, bool on, IEnumerable<Prerequisite> prerequisites,
ImmutableList<Target> targets, ImmutableList<Target> contextTargets, IEnumerable<FlagRule> rules, VariationOrRollout fallthrough, int? offVariation,
IEnumerable<LdValue> variations, string salt, bool trackEvents, bool trackEventsFallthrough, UnixMillisecondTime? debugEventsUntilDate,
bool clientSide, long? samplingRatio, bool excludeFromSummaries, Migration? migration)
bool clientSide, long? samplingRatio, bool excludeFromSummaries, Migration? migration, bool isOverride = false)
{
Key = key;
Version = version;
Expand All @@ -55,7 +62,18 @@ internal FeatureFlag(string key, int version, bool deleted, bool on, IEnumerable
SamplingRatio = samplingRatio;
ExcludeFromSummaries = excludeFromSummaries;
Migration = migration;
IsOverride = isOverride;
}

/// <summary>
/// Returns a copy of this flag that carries the override marker. The copy shares its immutable
/// parts with this flag, which is left unchanged.
/// </summary>
internal FeatureFlag AsOverride() =>
IsOverride ? this :
new FeatureFlag(Key, Version, Deleted, On, Prerequisites, Targets, ContextTargets, Rules, Fallthrough,
OffVariation, Variations, Salt, TrackEvents, TrackEventsFallthrough, DebugEventsUntilDate, ClientSide,
SamplingRatio, ExcludeFromSummaries, Migration, true);
}

internal struct Rollout
Expand Down
20 changes: 19 additions & 1 deletion pkgs/sdk/server/src/Internal/Model/Segment.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,13 @@ internal sealed class Segment : IJsonSerializable
internal int? Generation { get; }
internal PreprocessedData Preprocessed { get; }

/// <summary>
/// True if this definition came from the SDK's override store rather than from LaunchDarkly
/// data. The evaluator reads this marker to mark the evaluation as override-affected. The
/// marker lives on the model only. It is never serialized.
/// </summary>
internal bool IsOverride { get; }

internal Segment(
string key,
int version,
Expand All @@ -35,7 +42,8 @@ internal Segment(
string salt,
bool unbounded,
ContextKind? unboundedContextKind,
int? generation
int? generation,
bool isOverride = false
)
{
Key = key;
Expand All @@ -50,9 +58,19 @@ internal Segment(
Unbounded = unbounded;
UnboundedContextKind = unboundedContextKind;
Generation = generation;
IsOverride = isOverride;
Preprocessed = Preprocess(Included, Excluded);
}

/// <summary>
/// Returns a copy of this segment that carries the override marker. The copy shares its
/// immutable parts with this segment, which is left unchanged.
/// </summary>
internal Segment AsOverride() =>
IsOverride ? this :
new Segment(Key, Version, Deleted, Included, Excluded, IncludedContexts, ExcludedContexts, Rules, Salt,
Unbounded, UnboundedContextKind, Generation, true);

private static PreprocessedData Preprocess(IEnumerable<string> included, IEnumerable<string> excluded) =>
new PreprocessedData
{
Expand Down
12 changes: 6 additions & 6 deletions pkgs/sdk/server/test/DataModelTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -151,23 +151,23 @@ public void OptionalSegmentStringPropertiesAreNullable()
Assert.Null(segment1.Salt);
}

private FeatureFlag MustParseFlag(string json)
internal static FeatureFlag MustParseFlag(string json)
{
var item = DataModel.Features.Deserialize(json);
var flag = Assert.IsType<FeatureFlag>(item.Item);
Assert.Equal(flag.Version, item.Version);
return flag;
}

private Segment MustParseSegment(string json)
internal static Segment MustParseSegment(string json)
{
var item = DataModel.Segments.Deserialize(json);
var segment = Assert.IsType<Segment>(item.Item);
Assert.Equal(segment.Version, item.Version);
return segment;
}

private string FlagWithAllPropertiesJson() => @"{
internal static string FlagWithAllPropertiesJson() => @"{
""key"": ""flag-key"",
""version"": 99,
""deleted"": false,
Expand Down Expand Up @@ -229,7 +229,7 @@ private string FlagWithAllPropertiesJson() => @"{
""debugEventsUntilDate"": 1000
}";

private void AssertFlagHasAllProperties(FeatureFlag flag)
internal static void AssertFlagHasAllProperties(FeatureFlag flag)
{
Assert.Equal("flag-key", flag.Key);
Assert.Equal(99, flag.Version);
Expand Down Expand Up @@ -311,7 +311,7 @@ private void AssertFlagHasAllProperties(FeatureFlag flag)
Assert.Equal(UnixMillisecondTime.OfMillis(1000), flag.DebugEventsUntilDate);
}

private string SegmentWithAllPropertiesJson() => @"{
internal static string SegmentWithAllPropertiesJson() => @"{
""key"": ""segment-key"",
""version"": 99,
""deleted"": false,
Expand Down Expand Up @@ -347,7 +347,7 @@ private string SegmentWithAllPropertiesJson() => @"{
""generation"": 51
}";

private void AssertSegmentHasAllProperties(Segment segment)
internal static void AssertSegmentHasAllProperties(Segment segment)
{
Assert.Equal("segment-key", segment.Key);
Assert.Equal(99, segment.Version);
Expand Down
Loading
Loading