From ae86267635edec1ed8a8c585a0c960570f0c2a03 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Fri, 25 Sep 2026 15:27:41 -0700 Subject: [PATCH] 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. --- .../src/Internal/Evaluation/Evaluator.cs | 60 ++- .../Internal/Evaluation/EvaluatorClause.cs | 14 +- .../server/src/Internal/Model/FeatureFlag.cs | 20 +- pkgs/sdk/server/src/Internal/Model/Segment.cs | 20 +- pkgs/sdk/server/test/DataModelTest.cs | 12 +- .../Evaluation/EvaluatorOverridesTest.cs | 398 ++++++++++++++++++ pkgs/sdk/server/test/OverrideMarkerTest.cs | 93 ++++ 7 files changed, 595 insertions(+), 22 deletions(-) create mode 100644 pkgs/sdk/server/test/Internal/Evaluation/EvaluatorOverridesTest.cs create mode 100644 pkgs/sdk/server/test/OverrideMarkerTest.cs diff --git a/pkgs/sdk/server/src/Internal/Evaluation/Evaluator.cs b/pkgs/sdk/server/src/Internal/Evaluation/Evaluator.cs index b80118b6..26046fb4 100644 --- a/pkgs/sdk/server/src/Internal/Evaluation/Evaluator.cs +++ b/pkgs/sdk/server/src/Internal/Evaluation/Evaluator.cs @@ -48,6 +48,12 @@ internal struct EvalState internal ImmutableList.Builder PrereqEvals; internal Dictionary 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) { @@ -57,6 +63,7 @@ internal EvalState(Context context) PrereqEvals = null; BigSegmentsMembership = null; BigSegmentsStatus = null; + OverrideAffected = false; } } @@ -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.Null, null, EvaluationReason.ErrorReason(EvaluationErrorKind.UserNotSpecified)), + return new EvalResult(ErrorResult(EvaluationErrorKind.UserNotSpecified, state.OverrideAffected), ImmutableList.Create()); } try { - var state = new EvalState(context); var details = EvaluateInternal(ref state, flag); + var reason = details.Reason; if (state.BigSegmentsStatus.HasValue) { - details = new EvaluationDetail( - 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(details.Value, details.VariationIndex, reason); return new EvalResult(details, state.PrereqEvals is null ? ImmutableList.Create() : 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()); + return new EvalResult(ErrorResult(e.ErrorKind, state.OverrideAffected), + ImmutableList.Create()); } catch (Exception) { - return new EvalResult(ErrorResult(EvaluationErrorKind.Exception), ImmutableList.Create()); + return new EvalResult(ErrorResult(EvaluationErrorKind.Exception, state.OverrideAffected), + ImmutableList.Create()); } } @@ -166,6 +180,10 @@ private EvaluationDetail EvaluateInternal(ref EvalState state, FeatureF private static EvaluationDetail ErrorResult(EvaluationErrorKind kind) => new EvaluationDetail(LdValue.Null, null, EvaluationReason.ErrorReason(kind)); + private static EvaluationDetail ErrorResult(EvaluationErrorKind kind, bool overrideAffected) => + new EvaluationDetail(LdValue.Null, null, + EvaluationReason.ErrorReason(kind).WithOverrideAffected(overrideAffected)); + private EvaluationDetail GetVariation(FeatureFlag flag, int variation, in EvaluationReason reason) { if (variation < 0 || variation >= flag.Variations.Count()) @@ -213,7 +231,23 @@ private EvaluationDetail 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 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. @@ -225,7 +259,9 @@ private EvaluationDetail GetOffValue(FeatureFlag flag, in EvaluationRea { state.PrereqEvals = ImmutableList.CreateBuilder(); } - state.PrereqEvals.Add(new PrerequisiteEvalRecord(prereqFeatureFlag, flag.Key, prereqDetails)); + var prereqRecordDetails = new EvaluationDetail(prereqDetails.Value, prereqDetails.VariationIndex, + prereqDetails.Reason.WithOverrideAffected(prereqOverrideAffected)); + state.PrereqEvals.Add(new PrerequisiteEvalRecord(prereqFeatureFlag, flag.Key, prereqRecordDetails)); } if (!prereqOk) { diff --git a/pkgs/sdk/server/src/Internal/Evaluation/EvaluatorClause.cs b/pkgs/sdk/server/src/Internal/Evaluation/EvaluatorClause.cs index 73244855..edc832ee 100644 --- a/pkgs/sdk/server/src/Internal/Evaluation/EvaluatorClause.cs +++ b/pkgs/sdk/server/src/Internal/Evaluation/EvaluatorClause.cs @@ -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); diff --git a/pkgs/sdk/server/src/Internal/Model/FeatureFlag.cs b/pkgs/sdk/server/src/Internal/Model/FeatureFlag.cs index 612351b0..dc5b3406 100644 --- a/pkgs/sdk/server/src/Internal/Model/FeatureFlag.cs +++ b/pkgs/sdk/server/src/Internal/Model/FeatureFlag.cs @@ -31,10 +31,17 @@ internal sealed class FeatureFlag : IJsonSerializable public Migration? Migration { get; } + /// + /// 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. + /// + internal bool IsOverride { get; } + internal FeatureFlag(string key, int version, bool deleted, bool on, IEnumerable prerequisites, ImmutableList targets, ImmutableList contextTargets, IEnumerable rules, VariationOrRollout fallthrough, int? offVariation, IEnumerable 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; @@ -55,7 +62,18 @@ internal FeatureFlag(string key, int version, bool deleted, bool on, IEnumerable SamplingRatio = samplingRatio; ExcludeFromSummaries = excludeFromSummaries; Migration = migration; + IsOverride = isOverride; } + + /// + /// Returns a copy of this flag that carries the override marker. The copy shares its immutable + /// parts with this flag, which is left unchanged. + /// + 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 diff --git a/pkgs/sdk/server/src/Internal/Model/Segment.cs b/pkgs/sdk/server/src/Internal/Model/Segment.cs index 60fe946b..f2c2e6b7 100644 --- a/pkgs/sdk/server/src/Internal/Model/Segment.cs +++ b/pkgs/sdk/server/src/Internal/Model/Segment.cs @@ -23,6 +23,13 @@ internal sealed class Segment : IJsonSerializable internal int? Generation { get; } internal PreprocessedData Preprocessed { get; } + /// + /// 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. + /// + internal bool IsOverride { get; } + internal Segment( string key, int version, @@ -35,7 +42,8 @@ internal Segment( string salt, bool unbounded, ContextKind? unboundedContextKind, - int? generation + int? generation, + bool isOverride = false ) { Key = key; @@ -50,9 +58,19 @@ internal Segment( Unbounded = unbounded; UnboundedContextKind = unboundedContextKind; Generation = generation; + IsOverride = isOverride; Preprocessed = Preprocess(Included, Excluded); } + /// + /// Returns a copy of this segment that carries the override marker. The copy shares its + /// immutable parts with this segment, which is left unchanged. + /// + 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 included, IEnumerable excluded) => new PreprocessedData { diff --git a/pkgs/sdk/server/test/DataModelTest.cs b/pkgs/sdk/server/test/DataModelTest.cs index 3507de8a..f5c63626 100644 --- a/pkgs/sdk/server/test/DataModelTest.cs +++ b/pkgs/sdk/server/test/DataModelTest.cs @@ -151,7 +151,7 @@ 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(item.Item); @@ -159,7 +159,7 @@ private FeatureFlag MustParseFlag(string json) return flag; } - private Segment MustParseSegment(string json) + internal static Segment MustParseSegment(string json) { var item = DataModel.Segments.Deserialize(json); var segment = Assert.IsType(item.Item); @@ -167,7 +167,7 @@ private Segment MustParseSegment(string json) return segment; } - private string FlagWithAllPropertiesJson() => @"{ + internal static string FlagWithAllPropertiesJson() => @"{ ""key"": ""flag-key"", ""version"": 99, ""deleted"": false, @@ -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); @@ -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, @@ -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); diff --git a/pkgs/sdk/server/test/Internal/Evaluation/EvaluatorOverridesTest.cs b/pkgs/sdk/server/test/Internal/Evaluation/EvaluatorOverridesTest.cs new file mode 100644 index 00000000..8aa37156 --- /dev/null +++ b/pkgs/sdk/server/test/Internal/Evaluation/EvaluatorOverridesTest.cs @@ -0,0 +1,398 @@ +using System.Collections.Generic; +using System.Linq; +using LaunchDarkly.Sdk.Server.Internal.Model; +using Xunit; + +using static LaunchDarkly.Sdk.Server.Internal.Evaluation.EvaluatorTestUtil; +using static LaunchDarkly.Sdk.Server.Internal.Evaluation.EvaluatorTypes; + +namespace LaunchDarkly.Sdk.Server.Internal.Evaluation +{ + // Tests of the override-affected marking. An evaluation is marked when any definition it read + // carried the override marker: the evaluated flag, a prerequisite at any depth, or a segment + // consulted during matching. The marking propagates upward only. + public class EvaluatorOverridesTest + { + private static readonly Context context = Context.New("userkey"); + private static readonly LdValue offValue = LdValue.Of("off"); + private static readonly LdValue onValue = LdValue.Of("on"); + + // Builds a flag that is on and serves variation 1 ("on") by fallthrough, with optional + // prerequisites that must each serve variation 1. + private static FeatureFlagBuilder OverrideTestFlag(string key, params string[] prereqKeys) => + new FeatureFlagBuilder(key).On(true).FallthroughVariation(1).OffVariation(0) + .Variations(offValue, onValue) + .Prerequisites(prereqKeys.Select(k => new Prerequisite(k, 1)).ToArray()); + + private static void AssertOverrideAffected(bool expected, EvaluationDetail detail) => + Assert.Equal(expected, detail.Reason.OverrideAffected); + + private static PrerequisiteEvalRecord RequirePrereqRecord(EvalResult result, string prereqKey) + { + var records = result.PrerequisiteEvals.Where(r => r.PrerequisiteFlag.Key == prereqKey).ToList(); + Assert.True(records.Count == 1, "expected exactly one record for prerequisite " + prereqKey); + return records[0]; + } + + [Fact] + public void OverrideFlagMarksOffEvaluation() + { + var flag = new FeatureFlagBuilder("feature").On(false).OffVariation(0).Variations(offValue, onValue) + .Build().AsOverride(); + + var result = BasicEvaluator.Evaluate(flag, context); + + Assert.Equal(EvaluationReasonKind.Off, result.Result.Reason.Kind); + Assert.Equal(offValue, result.Result.Value); + AssertOverrideAffected(true, result.Result); + } + + [Fact] + public void OverrideFlagMarksFallthroughEvaluation() + { + var flag = OverrideTestFlag("feature").Build().AsOverride(); + + var result = BasicEvaluator.Evaluate(flag, context); + + Assert.Equal(EvaluationReasonKind.Fallthrough, result.Result.Reason.Kind); + Assert.Equal(onValue, result.Result.Value); + AssertOverrideAffected(true, result.Result); + } + + [Fact] + public void OverrideFlagMarksRuleMatchEvaluation() + { + var flag = new FeatureFlagBuilder("feature").On(true).FallthroughVariation(0).OffVariation(0) + .Variations(offValue, onValue) + .Rules(new RuleBuilder().Id("rule-id").Variation(1).Clauses(ClauseBuilder.ShouldMatchUser(context)).Build()) + .Build().AsOverride(); + + var result = BasicEvaluator.Evaluate(flag, context); + + Assert.Equal(EvaluationReasonKind.RuleMatch, result.Result.Reason.Kind); + Assert.Equal("rule-id", result.Result.Reason.RuleId); + Assert.Equal(onValue, result.Result.Value); + AssertOverrideAffected(true, result.Result); + } + + [Fact] + public void PlainFlagIsNotMarked() + { + var flag = new FeatureFlagBuilder("feature").On(false).OffVariation(0).Variations(offValue, onValue).Build(); + + var result = BasicEvaluator.Evaluate(flag, context); + + Assert.Equal(EvaluationReasonKind.Off, result.Result.Reason.Kind); + AssertOverrideAffected(false, result.Result); + } + + [Fact] + public void PlainFlagWithPlainPrerequisiteAndSegmentIsNotMarked() + { + var segment = new SegmentBuilder("segment").Included(context.Key).Build(); + var prereq = new FeatureFlagBuilder("prereq").BooleanMatchingSegment(segment.Key).Build(); + var flag = OverrideTestFlag("feature", "prereq").Build(); + var evaluator = BasicEvaluator.WithStoredFlags(prereq).WithStoredSegments(segment); + + var result = evaluator.Evaluate(flag, context); + + Assert.Equal(EvaluationReasonKind.Fallthrough, result.Result.Reason.Kind); + Assert.Equal(onValue, result.Result.Value); + AssertOverrideAffected(false, result.Result); + AssertOverrideAffected(false, RequirePrereqRecord(result, "prereq").Result); + } + + [Fact] + public void MalformedOverrideFlagErrorResultIsMarked() + { + var flag = new FeatureFlagBuilder("feature").On(true).FallthroughVariation(99).Variations(offValue, onValue) + .Build().AsOverride(); + + var result = BasicEvaluator.Evaluate(flag, context); + + Assert.Equal(EvaluationReasonKind.Error, result.Result.Reason.Kind); + Assert.Equal(EvaluationErrorKind.MalformedFlag, result.Result.Reason.ErrorKind); + Assert.Equal(LdValue.Null, result.Result.Value); + AssertOverrideAffected(true, result.Result); + } + + [Fact] + public void PrerequisiteCycleThroughOverrideFlagIsMarked() + { + // feature -> prereq -> feature; only prereq is an override + var prereq = OverrideTestFlag("prereq", "feature").Build().AsOverride(); + var flag = OverrideTestFlag("feature", "prereq").Build(); + var evaluator = BasicEvaluator.WithStoredFlags(flag, prereq); + + var result = evaluator.Evaluate(flag, context); + + Assert.Equal(EvaluationReasonKind.Error, result.Result.Reason.Kind); + Assert.Equal(EvaluationErrorKind.MalformedFlag, result.Result.Reason.ErrorKind); + AssertOverrideAffected(true, result.Result); + Assert.Empty(result.PrerequisiteEvals); + } + + [Fact] + public void PrerequisiteCycleWithoutOverrideIsNotMarked() + { + var prereq = OverrideTestFlag("prereq", "feature").Build(); + var flag = OverrideTestFlag("feature", "prereq").Build(); + var evaluator = BasicEvaluator.WithStoredFlags(flag, prereq); + + var result = evaluator.Evaluate(flag, context); + + Assert.Equal(EvaluationErrorKind.MalformedFlag, result.Result.Reason.ErrorKind); + AssertOverrideAffected(false, result.Result); + } + + [Fact] + public void InvalidContextErrorResultOfOverrideFlagIsMarked() + { + var flag = OverrideTestFlag("feature").Build().AsOverride(); + + var result = BasicEvaluator.Evaluate(flag, Context.New("")); + + Assert.Equal(EvaluationReasonKind.Error, result.Result.Reason.Kind); + Assert.Equal(EvaluationErrorKind.UserNotSpecified, result.Result.Reason.ErrorKind); + AssertOverrideAffected(true, result.Result); + } + + [Fact] + public void UnexpectedExceptionResultOfOverrideFlagIsMarked() + { + var flag = OverrideTestFlag("feature", "prereq").Build().AsOverride(); + // The basic evaluator throws when asked for any flag it does not know about. + var result = BasicEvaluator.Evaluate(flag, context); + + Assert.Equal(EvaluationErrorKind.Exception, result.Result.Reason.ErrorKind); + AssertOverrideAffected(true, result.Result); + } + + [Fact] + public void OverridePrerequisiteMarksPrerequisiteRecordAndTopLevel() + { + var prereq = OverrideTestFlag("prereq").Build().AsOverride(); + var flag = OverrideTestFlag("feature", "prereq").Build(); + var evaluator = BasicEvaluator.WithStoredFlags(prereq); + + var result = evaluator.Evaluate(flag, context); + + Assert.Equal(EvaluationReasonKind.Fallthrough, result.Result.Reason.Kind); + AssertOverrideAffected(true, result.Result); + + var record = RequirePrereqRecord(result, "prereq"); + Assert.Equal(EvaluationReasonKind.Fallthrough, record.Result.Reason.Kind); + Assert.Equal("feature", record.FlagKey); + AssertOverrideAffected(true, record.Result); + } + + [Fact] + public void OverrideFlagDoesNotMarkUnaffectedPrerequisiteRecord() + { + var prereq = OverrideTestFlag("prereq").Build(); + var flag = OverrideTestFlag("feature", "prereq").Build().AsOverride(); + var evaluator = BasicEvaluator.WithStoredFlags(prereq); + + var result = evaluator.Evaluate(flag, context); + + AssertOverrideAffected(true, result.Result); + AssertOverrideAffected(false, RequirePrereqRecord(result, "prereq").Result); + } + + [Fact] + public void OverridePrerequisiteAtDepthTwoMarksAllAffectedScopes() + { + var prereq2 = OverrideTestFlag("prereq2").Build().AsOverride(); + var prereq1 = OverrideTestFlag("prereq1", "prereq2").Build(); + var flag = OverrideTestFlag("feature", "prereq1").Build(); + var evaluator = BasicEvaluator.WithStoredFlags(prereq1, prereq2); + + var result = evaluator.Evaluate(flag, context); + + Assert.Equal(EvaluationReasonKind.Fallthrough, result.Result.Reason.Kind); + AssertOverrideAffected(true, result.Result); + + // The nested record is produced first, during the evaluation of prereq1. + Assert.Equal(2, result.PrerequisiteEvals.Count); + Assert.Equal("prereq2", result.PrerequisiteEvals[0].PrerequisiteFlag.Key); + AssertOverrideAffected(true, result.PrerequisiteEvals[0].Result); + Assert.Equal("prereq1", result.PrerequisiteEvals[1].PrerequisiteFlag.Key); + AssertOverrideAffected(true, result.PrerequisiteEvals[1].Result); + } + + [Fact] + public void UnaffectedSiblingPrerequisiteRecordStaysUnmarked() + { + // Flag a has prerequisites b and c. Only d, a prerequisite of b, is an override. The + // marking reaches a, b, and d. It does not reach the sibling c, and the plain segments s1 + // and s2 mark nothing. + var s1 = new SegmentBuilder("s1").Included(context.Key).Build(); + var s2 = new SegmentBuilder("s2").Included(context.Key).Build(); + var d = OverrideTestFlag("d").Build().AsOverride(); + var b = OverrideTestFlag("b", "d").Build(); + var c = new FeatureFlagBuilder("c").BooleanMatchingSegment(s1.Key).Build(); + var a = new FeatureFlagBuilder("a").On(true).FallthroughVariation(0).OffVariation(0) + .Prerequisites(new Prerequisite("b", 1), new Prerequisite("c", 1)) + .Rules(new RuleBuilder().Id("rule-s2").Variation(1).Clauses(ClauseBuilder.ShouldMatchSegment(s2.Key)).Build()) + .Variations(offValue, onValue) + .Build(); + var evaluator = BasicEvaluator.WithStoredFlags(b, c, d).WithStoredSegments(s1, s2); + + var result = evaluator.Evaluate(a, context); + + Assert.Equal(EvaluationReasonKind.RuleMatch, result.Result.Reason.Kind); + Assert.Equal(onValue, result.Result.Value); + AssertOverrideAffected(true, result.Result); + + Assert.Equal(3, result.PrerequisiteEvals.Count); + AssertOverrideAffected(true, RequirePrereqRecord(result, "d").Result); + AssertOverrideAffected(true, RequirePrereqRecord(result, "b").Result); + AssertOverrideAffected(false, RequirePrereqRecord(result, "c").Result); + } + + [Fact] + public void OverrideSegmentReferencedByFlagRuleMarksEvaluation() + { + var segment = new SegmentBuilder("segment").Included(context.Key).Build().AsOverride(); + var flag = new FeatureFlagBuilder("feature").BooleanMatchingSegment(segment.Key).Build(); + var evaluator = BasicEvaluator.WithStoredSegments(segment); + + var result = evaluator.Evaluate(flag, context); + + Assert.Equal(EvaluationReasonKind.RuleMatch, result.Result.Reason.Kind); + Assert.Equal(LdValue.Of(true), result.Result.Value); + AssertOverrideAffected(true, result.Result); + } + + [Fact] + public void OverrideSegmentReadWithoutMatchingMarksEvaluation() + { + var segment = new SegmentBuilder("segment").Included("someone-else").Build().AsOverride(); + var flag = new FeatureFlagBuilder("feature").BooleanMatchingSegment(segment.Key).Build(); + var evaluator = BasicEvaluator.WithStoredSegments(segment); + + var result = evaluator.Evaluate(flag, context); + + Assert.Equal(EvaluationReasonKind.Fallthrough, result.Result.Reason.Kind); + Assert.Equal(LdValue.Of(false), result.Result.Value); + AssertOverrideAffected(true, result.Result); + } + + [Fact] + public void OverrideSegmentReadByNegatedClauseMarksEvaluation() + { + var segment = new SegmentBuilder("segment").Included("someone-else").Build().AsOverride(); + var flag = new FeatureFlagBuilder("feature") + .BooleanWithClauses(ClauseBuilder.ShouldNotMatchSegment(segment.Key)).Build(); + var evaluator = BasicEvaluator.WithStoredSegments(segment); + + var result = evaluator.Evaluate(flag, context); + + Assert.Equal(EvaluationReasonKind.RuleMatch, result.Result.Reason.Kind); + Assert.Equal(LdValue.Of(true), result.Result.Value); + AssertOverrideAffected(true, result.Result); + } + + [Fact] + public void OverrideSegmentReferencedBySegmentRuleMarksEvaluation() + { + // The outer segment is plain. A rule of the outer segment reads a nested override segment. + var nested = new SegmentBuilder("nested-segment").Included(context.Key).Build().AsOverride(); + var outer = new SegmentBuilder("outer-segment") + .Rules(new SegmentRuleBuilder().Clauses(ClauseBuilder.ShouldMatchSegment(nested.Key)).Build()) + .Build(); + var flag = new FeatureFlagBuilder("feature").BooleanMatchingSegment(outer.Key).Build(); + var evaluator = BasicEvaluator.WithStoredSegments(outer, nested); + + var result = evaluator.Evaluate(flag, context); + + Assert.Equal(EvaluationReasonKind.RuleMatch, result.Result.Reason.Kind); + Assert.Equal(LdValue.Of(true), result.Result.Value); + AssertOverrideAffected(true, result.Result); + } + + [Fact] + public void OverrideSegmentReferencedByPrerequisiteMarksPrerequisiteRecordAndTopLevel() + { + var segment = new SegmentBuilder("segment").Included(context.Key).Build().AsOverride(); + var prereq = new FeatureFlagBuilder("prereq").BooleanMatchingSegment(segment.Key).Build(); + var flag = OverrideTestFlag("feature", "prereq").Build(); + var evaluator = BasicEvaluator.WithStoredFlags(prereq).WithStoredSegments(segment); + + var result = evaluator.Evaluate(flag, context); + + Assert.Equal(EvaluationReasonKind.Fallthrough, result.Result.Reason.Kind); + AssertOverrideAffected(true, result.Result); + AssertOverrideAffected(true, RequirePrereqRecord(result, "prereq").Result); + } + + [Fact] + public void MissingPrerequisiteDoesNotMarkEvaluation() + { + // The store holds an unrelated override definition to show that only reads count. + var unrelated = OverrideTestFlag("unrelated").Build().AsOverride(); + var flag = OverrideTestFlag("feature", "missing").Build(); + var evaluator = BasicEvaluator.WithStoredFlags(unrelated).WithNonexistentFlag("missing"); + + var result = evaluator.Evaluate(flag, context); + + Assert.Equal(EvaluationReason.PrerequisiteFailedReason("missing"), result.Result.Reason); + Assert.Equal(offValue, result.Result.Value); + AssertOverrideAffected(false, result.Result); + Assert.Empty(result.PrerequisiteEvals); + } + + [Fact] + public void MissingSegmentDoesNotMarkEvaluation() + { + var unrelated = new SegmentBuilder("unrelated").Included(context.Key).Build().AsOverride(); + var flag = new FeatureFlagBuilder("feature").BooleanMatchingSegment("missing").Build(); + var evaluator = BasicEvaluator.WithStoredSegments(unrelated).WithNonexistentSegment("missing"); + + var result = evaluator.Evaluate(flag, context); + + Assert.Equal(EvaluationReasonKind.Fallthrough, result.Result.Reason.Kind); + Assert.Equal(LdValue.Of(false), result.Result.Value); + AssertOverrideAffected(false, result.Result); + } + + [Theory] + [InlineData(false, false)] + [InlineData(true, false)] + [InlineData(false, true)] + [InlineData(true, true)] + public void TopLevelAndPrerequisiteRecordsReportTheirOwnMarking(bool flagOverride, bool prereqOverride) + { + var prereq = OverrideTestFlag("prereq").Build(); + if (prereqOverride) + { + prereq = prereq.AsOverride(); + } + var flag = OverrideTestFlag("feature", "prereq").Build(); + if (flagOverride) + { + flag = flag.AsOverride(); + } + var evaluator = BasicEvaluator.WithStoredFlags(prereq); + + var result = evaluator.Evaluate(flag, context); + + AssertOverrideAffected(flagOverride || prereqOverride, result.Result); + AssertOverrideAffected(prereqOverride, RequirePrereqRecord(result, "prereq").Result); + } + + [Fact] + public void MarkingIsCombinedWithBigSegmentsStatus() + { + var segment = new SegmentBuilder("segment").Unbounded(true).Generation(1).Build().AsOverride(); + var flag = new FeatureFlagBuilder("feature").BooleanMatchingSegment(segment.Key).Build(); + var bigSegments = new MockBigSegmentProvider { Status = BigSegmentsStatus.Stale }; + var evaluator = BasicEvaluator.WithStoredSegments(segment).WithBigSegments(bigSegments); + + var result = evaluator.Evaluate(flag, context); + + Assert.Equal(BigSegmentsStatus.Stale, result.Result.Reason.BigSegmentsStatus); + AssertOverrideAffected(true, result.Result); + } + } +} diff --git a/pkgs/sdk/server/test/OverrideMarkerTest.cs b/pkgs/sdk/server/test/OverrideMarkerTest.cs new file mode 100644 index 00000000..eda6e69c --- /dev/null +++ b/pkgs/sdk/server/test/OverrideMarkerTest.cs @@ -0,0 +1,93 @@ +using LaunchDarkly.Sdk.Server.Internal.Model; +using Xunit; + +using static LaunchDarkly.Sdk.Server.Subsystems.DataStoreTypes; +using static LaunchDarkly.TestHelpers.JsonAssertions; + +namespace LaunchDarkly.Sdk.Server +{ + // The override marker is carried on the flag and segment models only. It is set on a copy + // and it never appears in the JSON representation. + public class OverrideMarkerTest + { + [Fact] + public void FlagIsNotAnOverrideByDefault() + { + var flag = DataModelTest.MustParseFlag(DataModelTest.FlagWithAllPropertiesJson()); + Assert.False(flag.IsOverride); + } + + [Fact] + public void FlagAsOverrideMarksACopyAndKeepsEveryOtherProperty() + { + var json = DataModelTest.FlagWithAllPropertiesJson(); + var flag = DataModelTest.MustParseFlag(json); + + var marked = flag.AsOverride(); + + Assert.True(marked.IsOverride); + Assert.False(flag.IsOverride); + Assert.NotSame(flag, marked); + DataModelTest.AssertFlagHasAllProperties(marked); + AssertJsonEqual(json, DataModel.Features.Serialize(new ItemDescriptor(marked.Version, marked))); + } + + [Fact] + public void FlagMarkerIsNeverSerialized() + { + var flag = new FeatureFlagBuilder("flag").Version(3).OnWithValue(LdValue.Of(true)).Build(); + var plainJson = DataModel.Features.Serialize(new ItemDescriptor(3, flag)); + var markedJson = DataModel.Features.Serialize(new ItemDescriptor(3, flag.AsOverride())); + Assert.Equal(plainJson, markedJson); + Assert.DoesNotContain("override", markedJson); + } + + [Fact] + public void AsOverrideOnAMarkedFlagReturnsTheSameInstance() + { + var marked = new FeatureFlagBuilder("flag").Build().AsOverride(); + Assert.Same(marked, marked.AsOverride()); + } + + [Fact] + public void SegmentIsNotAnOverrideByDefault() + { + var segment = DataModelTest.MustParseSegment(DataModelTest.SegmentWithAllPropertiesJson()); + Assert.False(segment.IsOverride); + } + + [Fact] + public void SegmentAsOverrideMarksACopyAndKeepsEveryOtherProperty() + { + var json = DataModelTest.SegmentWithAllPropertiesJson(); + var segment = DataModelTest.MustParseSegment(json); + + var marked = segment.AsOverride(); + + Assert.True(marked.IsOverride); + Assert.False(segment.IsOverride); + Assert.NotSame(segment, marked); + DataModelTest.AssertSegmentHasAllProperties(marked); + AssertJsonEqual(json, DataModel.Segments.Serialize(new ItemDescriptor(marked.Version, marked))); + Assert.Equal(segment.Preprocessed.IncludedSet, marked.Preprocessed.IncludedSet); + Assert.Equal(segment.Preprocessed.ExcludedSet, marked.Preprocessed.ExcludedSet); + } + + [Fact] + public void SegmentMarkerIsNeverSerialized() + { + var segment = new SegmentBuilder("segment").Version(3).Included("user").Build(); + var plainJson = DataModel.Segments.Serialize(new ItemDescriptor(3, segment)); + var markedJson = DataModel.Segments.Serialize(new ItemDescriptor(3, segment.AsOverride())); + Assert.Equal(plainJson, markedJson); + Assert.DoesNotContain("override", markedJson); + } + + [Fact] + public void AsOverrideOnAMarkedSegmentReturnsTheSameInstance() + { + var marked = new SegmentBuilder("segment").Build().AsOverride(); + Assert.Same(marked, marked.AsOverride()); + } + } +}