diff --git a/pkgs/sdk/server/src/Integrations/DataSystemBuilder.cs b/pkgs/sdk/server/src/Integrations/DataSystemBuilder.cs index dcd66f6cb..56bd3257a 100644 --- a/pkgs/sdk/server/src/Integrations/DataSystemBuilder.cs +++ b/pkgs/sdk/server/src/Integrations/DataSystemBuilder.cs @@ -20,7 +20,9 @@ public sealed class DataSystemBuilder private IComponentConfigurer _persistentStore; private DataSystemConfiguration.DataStoreMode _persistentDataStoreMode; - + + private IComponentConfigurer _overrideSource; + /// /// Add one or more initializers to the builder. /// To replace initializers, please refer to . @@ -101,6 +103,38 @@ public DataSystemBuilder PersistentStore(IComponentConfigurer persis return this; } + /// + /// Configures an override source. Flag overrides are currently experimental and subject to change. + /// + /// + /// + /// The source supplies flag and segment definitions that take precedence over data received from + /// LaunchDarkly on a per-key basis. Overrides let an operator force one or more flags to a known + /// state on a running client, whether or not the client can reach LaunchDarkly. Flags not present + /// in the override data are unaffected. + /// + /// + /// The override source is not a data source. It has no effect on the client's initialization status + /// or data source status. Configuring it changes nothing until the source actually supplies an + /// override. At most one override source can be configured. A later call replaces the earlier one. + /// + /// + /// + /// var config = Configuration.Builder("my-sdk-key") + /// .DataSystem(Components.DataSystem().Default() + /// .Overrides(FileOverrides.Source().FilePaths("/etc/launchdarkly/overrides.json"))); + /// + /// + /// + /// the override source, such as the file-based override source; + /// null removes a previously configured source + /// a reference to the builder + public DataSystemBuilder Overrides(IComponentConfigurer overrideSource) + { + _overrideSource = overrideSource; + return this; + } + internal DataSystemConfiguration Build() { // This function should remain internal. @@ -110,7 +144,8 @@ internal DataSystemConfiguration Build() _synchronizers.ToImmutableList(), _fdV1FallbackSynchronizer, _persistentStore, - _persistentDataStoreMode); + _persistentDataStoreMode, + _overrideSource); } } } diff --git a/pkgs/sdk/server/src/Internal/DataSources/DataSourceUpdatesImpl.cs b/pkgs/sdk/server/src/Internal/DataSources/DataSourceUpdatesImpl.cs index 19c4c5236..76748a8a3 100644 --- a/pkgs/sdk/server/src/Internal/DataSources/DataSourceUpdatesImpl.cs +++ b/pkgs/sdk/server/src/Internal/DataSources/DataSourceUpdatesImpl.cs @@ -197,7 +197,25 @@ internal async Task WaitForAsync(DataSourceState desiredState, TimeSpan ti #region Private methods - private bool HasFlagChangeListeners() => FlagChanged != null; + internal bool HasFlagChangeListeners() => FlagChanged != null; + + /// + /// Sends a flag change event for each of the given flag keys. The override layer uses this to + /// report the flags that an override update affected, through the same path as a change from + /// LaunchDarkly. + /// + internal void SendFlagChangeEvents(IEnumerable flagKeys) + { + var copyOfHandlers = FlagChanged; + if (copyOfHandlers == null) + { + return; + } + foreach (var key in flagKeys) + { + _taskExecutor.ScheduleEvent(new FlagChangeEvent(key), copyOfHandlers); + } + } private void SendChangeEvents(IEnumerable affectedItems) { diff --git a/pkgs/sdk/server/src/Internal/DataSystem/FDv1DataSystem.cs b/pkgs/sdk/server/src/Internal/DataSystem/FDv1DataSystem.cs index f8f23f17f..9f91b6212 100644 --- a/pkgs/sdk/server/src/Internal/DataSystem/FDv1DataSystem.cs +++ b/pkgs/sdk/server/src/Internal/DataSystem/FDv1DataSystem.cs @@ -43,6 +43,11 @@ public Task Start() public bool Initialized => _dataSource.Initialized; + // Overrides are an option of the FDv2 data system only. + public bool OverridesConfigured => false; + + public bool HasOverride(DataStoreTypes.DataKind kind, string key) => false; + public IFlagChanged FlagChanged { get; } public IDataSourceStatusProvider DataSourceStatusProvider { get; } public IDataStoreStatusProvider DataStoreStatusProvider { get; } diff --git a/pkgs/sdk/server/src/Internal/DataSystem/FDv2DataSystem.cs b/pkgs/sdk/server/src/Internal/DataSystem/FDv2DataSystem.cs index 3d4deaf2d..bf8b0c564 100644 --- a/pkgs/sdk/server/src/Internal/DataSystem/FDv2DataSystem.cs +++ b/pkgs/sdk/server/src/Internal/DataSystem/FDv2DataSystem.cs @@ -7,6 +7,7 @@ using LaunchDarkly.Sdk.Server.Internal.DataSources; using LaunchDarkly.Sdk.Server.Internal.DataStores; using LaunchDarkly.Sdk.Server.Internal.FDv2DataSources; +using LaunchDarkly.Sdk.Server.Internal.Overrides; using LaunchDarkly.Sdk.Server.Subsystems; namespace LaunchDarkly.Sdk.Server.Internal.DataSystem @@ -16,16 +17,44 @@ internal class FDv2DataSystem : IDataSystem, IDisposable private readonly WriteThroughStore _store; private readonly IDataSource _dataSource; private readonly DataSourceUpdatesImpl _dataSourceUpdates; + // The override source, its layer, and its sink are null when no override source is configured. + private readonly IOverrideSource _overrideSource; + private readonly OverrideLayer _overrideLayer; + private readonly OverrideSink _overrideSink; private bool _disposed; #region IDataSystem implementation public IReadOnlyStore Store { get; } - public Task Start() => _dataSource.Start(); + public Task Start() + { + // The override source starts before the data sources, and its initial load completes + // synchronously, so overrides present when the client starts are in effect before the + // client evaluates anything. The source has no effect on initialization. A source that + // fails to start fails the client constructor; the data system is disposed first, so + // nothing it created is left running. + try + { + _overrideSource?.Start(_overrideSink); + } + catch (Exception) + { + Dispose(); + throw; + } + return _dataSource.Start(); + } public bool Initialized => _dataSource.Initialized; + public bool OverridesConfigured => _overrideSource != null; + + // No entry has a null key, so a null key is not held. The evaluation of a null key then + // takes the same path as without an override source. + public bool HasOverride(DataStoreTypes.DataKind kind, string key) => + key != null && _overrideLayer != null && _overrideLayer.Get(kind, key).HasValue; + public IFlagChanged FlagChanged { get; } public IDataSourceStatusProvider DataSourceStatusProvider { get; } public IDataStoreStatusProvider DataStoreStatusProvider { get; } @@ -34,10 +63,14 @@ internal class FDv2DataSystem : IDataSystem, IDisposable private FDv2DataSystem( WriteThroughStore store, + IReadOnlyStore readOnlyStore, IDataSource dataSource, IDataSourceStatusProvider dataSourceStatusProvider, IDataStoreStatusProvider dataStoreStatusProvider, - DataSourceUpdatesImpl dataStoreUpdates + DataSourceUpdatesImpl dataStoreUpdates, + IOverrideSource overrideSource, + OverrideLayer overrideLayer, + OverrideSink overrideSink ) { _store = store; @@ -46,7 +79,10 @@ DataSourceUpdatesImpl dataStoreUpdates DataSourceStatusProvider = dataSourceStatusProvider; FlagChanged = new FlagChangedFacade(dataStoreUpdates); _dataSourceUpdates = dataStoreUpdates; - Store = new ReadonlyStoreFacade(store); + Store = readOnlyStore; + _overrideSource = overrideSource; + _overrideLayer = overrideLayer; + _overrideSink = overrideSink; } public static FDv2DataSystem Create(Logger logger, Configuration configuration, LdClientContext clientContext, @@ -98,8 +134,40 @@ public static FDv2DataSystem Create(Logger logger, Configuration configuration, var dataSourceStatusProvider = new DataSourceStatusProviderImpl(dataSourceUpdates); - return new FDv2DataSystem(writeThroughStore, compositeDataSource, dataSourceStatusProvider, - dataStoreStatusProvider, dataSourceUpdates); + // The override layer sits at the store read boundary. Every read the client performs + // goes through the overlay, so evaluation, prerequisite and segment resolution, and + // the all-flags state see override entries in preference to LaunchDarkly data. The + // overlay never touches the data system's own writes or its initialization status. + IReadOnlyStore readOnlyStore = new ReadonlyStoreFacade(writeThroughStore); + IOverrideSource overrideSource = null; + OverrideLayer overrideLayer = null; + OverrideSink overrideSink = null; + if (dataSystemConfiguration.OverrideSource != null && !configuration.Offline) + { + // A configuration error in the source is reported like any other invalid component + // configuration: the exception propagates out of the client constructor. The + // components created so far are disposed first, so a failed constructor leaves + // nothing running. + try + { + overrideSource = dataSystemConfiguration.OverrideSource.Build(clientContext); + } + catch (Exception) + { + compositeDataSource.Dispose(); + dataSourceUpdates.Dispose(); + writeThroughStore.Dispose(); + throw; + } + overrideLayer = new OverrideLayer(); + overrideSink = new OverrideSink(overrideLayer, readOnlyStore, + dataSourceUpdates.SendFlagChangeEvents, dataSourceUpdates.HasFlagChangeListeners, + logger.SubLogger(LogNames.OverridesSubLog)); + readOnlyStore = new OverrideOverlay(readOnlyStore, overrideLayer); + } + + return new FDv2DataSystem(writeThroughStore, readOnlyStore, compositeDataSource, dataSourceStatusProvider, + dataStoreStatusProvider, dataSourceUpdates, overrideSource, overrideLayer, overrideSink); } private static Func, SourceFactory> FactoryWithContext( @@ -125,6 +193,7 @@ private void Dispose(bool disposing) if (_disposed) return; if (disposing) { + _overrideSource?.Dispose(); _dataSource.Dispose(); _store.Dispose(); _dataSourceUpdates.Dispose(); diff --git a/pkgs/sdk/server/src/Internal/DataSystem/IDataSystem.cs b/pkgs/sdk/server/src/Internal/DataSystem/IDataSystem.cs index 849adf5c2..6b5da2b37 100644 --- a/pkgs/sdk/server/src/Internal/DataSystem/IDataSystem.cs +++ b/pkgs/sdk/server/src/Internal/DataSystem/IDataSystem.cs @@ -27,6 +27,18 @@ internal interface IDataSystem Task Start(); bool Initialized { get; } + /// + /// True if the data system was built with an override source. When true, the store applies the + /// override layer, and the client consults it before its not-initialized short-circuit. + /// + bool OverridesConfigured { get; } + + /// + /// True if the override layer holds an entry of the kind with the key. Always false when no + /// override source is configured. + /// + bool HasOverride(DataStoreTypes.DataKind kind, string key); + IFlagChanged FlagChanged { get; } IDataSourceStatusProvider DataSourceStatusProvider { get; } diff --git a/pkgs/sdk/server/src/Internal/LogNames.cs b/pkgs/sdk/server/src/Internal/LogNames.cs index 35706aaa9..750539d11 100644 --- a/pkgs/sdk/server/src/Internal/LogNames.cs +++ b/pkgs/sdk/server/src/Internal/LogNames.cs @@ -18,5 +18,7 @@ internal static class LogNames internal const string EventsSubLog = "Events"; internal const string HooksSubLog = "Hooks"; + + internal const string OverridesSubLog = "Overrides"; } } diff --git a/pkgs/sdk/server/src/Internal/Overrides/OverrideChanges.cs b/pkgs/sdk/server/src/Internal/Overrides/OverrideChanges.cs new file mode 100644 index 000000000..e0bdbcfe1 --- /dev/null +++ b/pkgs/sdk/server/src/Internal/Overrides/OverrideChanges.cs @@ -0,0 +1,181 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using LaunchDarkly.Logging; +using LaunchDarkly.Sdk.Internal; +using LaunchDarkly.Sdk.Server.Internal.DataSources; +using LaunchDarkly.Sdk.Server.Internal.DataSystem; + +using static LaunchDarkly.Sdk.Server.Subsystems.DataStoreTypes; + +namespace LaunchDarkly.Sdk.Server.Internal.Overrides +{ + /// + /// Computes which flags an override layer replacement may have changed, so that flag change + /// listeners hear about them the same as for a change from LaunchDarkly. + /// + internal static class OverrideChanges + { + private static readonly DataKind[] DiffKinds = { DataModel.Features, DataModel.Segments }; + + /// + /// Returns the keys of all flags whose merged-view evaluation may have changed when the + /// override layer was replaced. The result includes the flags whose override entries were + /// added, removed, or changed. Dependency fan-out adds every flag that depends, directly or + /// transitively, on any added, removed, or changed entry of either kind. A definition whose + /// dependencies cannot be computed is logged and contributes no edges. + /// + internal static ICollection ComputeAffectedFlags( + ImmutableDictionary> oldOverrides, + ImmutableDictionary> newOverrides, + IDictionary> oldMerged, + IDictionary> newMerged, + Logger log + ) + { + var seeds = DiffOverrides(oldOverrides, newOverrides); + if (seeds.Count == 0) + { + return new List(); + } + + // Dependency edges are computed over both the old and the new merged views, because a + // replacement can rewire dependencies. For example, removing a flag override restores the + // prerequisite edges of the LaunchDarkly definition. Flags that depended on the override's + // references exist as dependents only in the old view. + var oldTracker = TrackerFromView(oldMerged, log); + var newTracker = TrackerFromView(newMerged, log); + var affected = new HashSet(); + foreach (var seed in seeds) + { + oldTracker.AddAffectedItems(affected, seed); + newTracker.AddAffectedItems(affected, seed); + } + + var flagKeys = new List(); + foreach (var item in affected) + { + if (item.Kind == DataModel.Features) + { + flagKeys.Add(item.Key); + } + } + return flagKeys; + } + + // Returns an entry for each key whose override entry differs between the two layer + // snapshots. An added or removed entry is always a change, even when its content matches the + // underlying LaunchDarkly data, because the override marker alone changes the served entry. + // Entries present in both snapshots are compared by their serialized form. The layer is + // rebuilt wholesale on every update, so reference or version comparison would report every + // retained entry as changed. + private static List DiffOverrides( + ImmutableDictionary> oldOverrides, + ImmutableDictionary> newOverrides + ) + { + var seeds = new List(); + foreach (var kind in DiffKinds) + { + var oldItems = ItemsOf(oldOverrides, kind); + var newItems = ItemsOf(newOverrides, kind); + foreach (var kv in oldItems) + { + if (!newItems.TryGetValue(kv.Key, out var newItem) || !ItemsEqual(kind, kv.Value, newItem)) + { + seeds.Add(new KindAndKey(kind, kv.Key)); + } + } + foreach (var kv in newItems) + { + if (!oldItems.ContainsKey(kv.Key)) + { + seeds.Add(new KindAndKey(kind, kv.Key)); + } + } + } + return seeds; + } + + private static ImmutableDictionary ItemsOf( + ImmutableDictionary> contents, + DataKind kind + ) => + contents.TryGetValue(kind, out var items) ? items : ImmutableDictionary.Empty; + + private static bool ItemsEqual(DataKind kind, ItemDescriptor a, ItemDescriptor b) + { + if (a.Version != b.Version) + { + return false; + } + return kind.Serialize(a) == kind.Serialize(b); + } + + /// + /// Captures the merged view of a base store and a layer snapshot: base data with the override + /// entries overlaid. A base read failure for a kind yields just the overrides for that kind. + /// This degrades the dependency fan-out but never loses the directly changed keys. + /// + internal static IDictionary> SnapshotMergedView( + IReadOnlyStore baseStore, + ImmutableDictionary> overrides + ) + { + var view = new Dictionary>(); + foreach (var kind in DiffKinds) + { + var items = new Dictionary(); + try + { + foreach (var kv in baseStore.GetAll(kind).Items) + { + items[kv.Key] = kv.Value; + } + } + catch (Exception) + { + // The base store could not be read. The view holds only the overrides. + } + foreach (var kv in ItemsOf(overrides, kind)) + { + items[kv.Key] = kv.Value; + } + view[kind] = items; + } + return view; + } + + private static DependencyTracker TrackerFromView( + IDictionary> view, + Logger log + ) + { + var tracker = new DependencyTracker(); + foreach (var kind in DiffKinds) + { + if (view.TryGetValue(kind, out var items)) + { + foreach (var kv in items) + { + try + { + tracker.UpdateDependenciesFrom(kind, kv.Key, kv.Value); + } + catch (Exception e) + { + // A malformed definition, such as a prerequisite without a key or a + // segment match on a value that is not a string, has no usable + // dependencies. It contributes no edges, so the fan-out does not reach + // the flags that depend on it. Every directly changed key is still + // notified. + log.Warn("Unable to compute the dependencies of {0} \"{1}\": {2}", + kind.Name, kv.Key, LogValues.ExceptionSummary(e)); + } + } + } + } + return tracker; + } + } +} diff --git a/pkgs/sdk/server/src/Internal/Overrides/OverrideLayer.cs b/pkgs/sdk/server/src/Internal/Overrides/OverrideLayer.cs new file mode 100644 index 000000000..377681aff --- /dev/null +++ b/pkgs/sdk/server/src/Internal/Overrides/OverrideLayer.cs @@ -0,0 +1,111 @@ +using System.Collections.Immutable; +using LaunchDarkly.Sdk.Server.Internal.Model; + +using static LaunchDarkly.Sdk.Server.Subsystems.DataStoreTypes; + +namespace LaunchDarkly.Sdk.Server.Internal.Overrides +{ + /// + /// The override store: a thread-safe collection of override entries, keyed by data kind and key, + /// that is replaced wholesale on each update from an override source. + /// + /// + /// The contents are an immutable map that is swapped on update, so a reader always sees exactly + /// one snapshot. Each flag or segment is stored as a marked copy. The copy shares its immutable + /// parts with the caller's entity, and the caller's entity is never marked. A source may retain + /// the entities it supplied and supply them again. + /// + internal sealed class OverrideLayer + { + internal static readonly ImmutableDictionary> EmptyContents = + ImmutableDictionary>.Empty; + + private static readonly ImmutableDictionary NoItems = + ImmutableDictionary.Empty; + + private readonly object _writeLock = new object(); + private volatile ImmutableDictionary> _contents = EmptyContents; + + /// + /// True if the layer holds no entries. + /// + internal bool IsEmpty + { + get + { + foreach (var kv in _contents) + { + if (kv.Value.Count > 0) + { + return false; + } + } + return true; + } + } + + /// + /// Atomically replaces the entire layer contents. An empty data set clears the layer. + /// + /// the new contents + /// receives the contents before the replacement + /// receives the contents after the replacement + internal void SetAll( + FullDataSet data, + out ImmutableDictionary> previous, + out ImmutableDictionary> current + ) + { + var builder = ImmutableDictionary.CreateBuilder>(); + foreach (var kindAndItems in data.Data) + { + var items = ImmutableDictionary.CreateBuilder(); + foreach (var keyAndItem in kindAndItems.Value.Items) + { + items[keyAndItem.Key] = MarkedCopy(keyAndItem.Value); + } + builder[kindAndItems.Key] = items.ToImmutable(); + } + var replacement = builder.ToImmutable(); + lock (_writeLock) + { + previous = _contents; + _contents = replacement; + } + current = replacement; + } + + /// + /// Returns the override entry for a key, or null if the layer has none. + /// + internal ItemDescriptor? Get(DataKind kind, string key) + { + if (_contents.TryGetValue(kind, out var items) && items.TryGetValue(key, out var item)) + { + return item; + } + return null; + } + + /// + /// Returns the entries of the given kind. The result is an immutable snapshot. + /// + internal ImmutableDictionary All(DataKind kind) => + _contents.TryGetValue(kind, out var items) ? items : NoItems; + + // Returns a copy of the item whose entity carries the override marker. An item of another + // type, or a deleted-item placeholder, is returned as is. + internal static ItemDescriptor MarkedCopy(ItemDescriptor item) + { + switch (item.Item) + { + case FeatureFlag flag: + return new ItemDescriptor(item.Version, flag.AsOverride()); + case Segment segment: + return new ItemDescriptor(item.Version, segment.AsOverride()); + default: + return item; + } + } + } +} diff --git a/pkgs/sdk/server/src/Internal/Overrides/OverrideOverlay.cs b/pkgs/sdk/server/src/Internal/Overrides/OverrideOverlay.cs new file mode 100644 index 000000000..7c41b6632 --- /dev/null +++ b/pkgs/sdk/server/src/Internal/Overrides/OverrideOverlay.cs @@ -0,0 +1,99 @@ +using System; +using System.Collections.Generic; +using LaunchDarkly.Sdk.Server.Internal.DataSystem; + +using static LaunchDarkly.Sdk.Server.Subsystems.DataStoreTypes; + +namespace LaunchDarkly.Sdk.Server.Internal.Overrides +{ + /// + /// Merges an over a base store at the store read boundary. A read for + /// a key returns the override entry when one exists, and the base entry otherwise. Targeting + /// rules, prerequisites, and segment matches behave identically for overridden and ordinary data + /// because they are the same reads through the same boundary. + /// + internal sealed class OverrideOverlay : IReadOnlyStore + { + private readonly IReadOnlyStore _base; + private readonly OverrideLayer _layer; + + internal OverrideOverlay(IReadOnlyStore baseStore, OverrideLayer layer) + { + _base = baseStore; + _layer = layer; + } + + /// + /// Returns the override entry for the key if one exists, and otherwise delegates to the base + /// store. This works when the base store is uninitialized, because an uninitialized base + /// reports not found rather than failing. + /// + public ItemDescriptor? Get(DataKind kind, string key) + { + var item = _layer.Get(kind, key); + if (item.HasValue) + { + return item; + } + return _base.Get(kind, key); + } + + /// + /// Returns the union of the base store's items and the layer's items. The override entry wins + /// for any key present in both, including a key the base holds as a deleted-item placeholder. + /// + /// + /// When the base store fails and the layer holds entries, the result is the layer's entries + /// alone. A per-key read serves those entries whatever the state of the base, so an all-flags + /// read does the same. When the layer is empty, the base failure propagates. + /// + public KeyedItems GetAll(DataKind kind) + { + var overrideItems = _layer.All(kind); + KeyedItems baseItems; + try + { + baseItems = _base.GetAll(kind); + } + catch (Exception) when (overrideItems.Count > 0) + { + return new KeyedItems(overrideItems); + } + if (overrideItems.Count == 0) + { + return baseItems; + } + + var result = new List>(); + var seen = new HashSet(); + foreach (var keyAndItem in baseItems.Items) + { + seen.Add(keyAndItem.Key); + if (overrideItems.TryGetValue(keyAndItem.Key, out var overrideItem)) + { + result.Add(new KeyValuePair(keyAndItem.Key, overrideItem)); + } + else + { + result.Add(keyAndItem); + } + } + foreach (var keyAndItem in overrideItems) + { + if (!seen.Contains(keyAndItem.Key)) + { + result.Add(keyAndItem); + } + } + return new KeyedItems(result); + } + + /// + /// Delegates to the base store. The override layer never affects initialization status or + /// data availability. + /// + public bool Initialized() => _base.Initialized(); + + public InitMetadata GetMetadata() => _base.GetMetadata(); + } +} diff --git a/pkgs/sdk/server/src/Internal/Overrides/OverrideSink.cs b/pkgs/sdk/server/src/Internal/Overrides/OverrideSink.cs new file mode 100644 index 000000000..15fcca1d3 --- /dev/null +++ b/pkgs/sdk/server/src/Internal/Overrides/OverrideSink.cs @@ -0,0 +1,89 @@ +using System; +using System.Collections.Generic; +using LaunchDarkly.Logging; +using LaunchDarkly.Sdk.Internal; +using LaunchDarkly.Sdk.Server.Internal.DataSystem; +using LaunchDarkly.Sdk.Server.Subsystems; + +using static LaunchDarkly.Sdk.Server.Subsystems.DataStoreTypes; + +namespace LaunchDarkly.Sdk.Server.Internal.Overrides +{ + /// + /// Applies override layer replacements supplied by an override source, and notifies flag change + /// listeners of the flags affected by each replacement. + /// + internal sealed class OverrideSink : IOverrideSink + { + private readonly object _lock = new object(); + private readonly OverrideLayer _layer; + private readonly IReadOnlyStore _base; + private readonly Action> _notify; + private readonly Func _hasListeners; + private readonly Logger _log; + + /// + /// Creates a sink that writes to the given layer. + /// + /// the override layer + /// the store holding LaunchDarkly data, without the overlay. Merged-view + /// snapshots for change computation are built from it plus the layer. + /// sends flag change notifications for the given flag keys + /// reports whether anything listens for flag changes + /// the destination for log output + internal OverrideSink( + OverrideLayer layer, + IReadOnlyStore baseStore, + Action> notify, + Func hasListeners, + Logger log + ) + { + _layer = layer; + _base = baseStore; + _notify = notify; + _hasListeners = hasListeners; + _log = log; + } + + /// + /// Atomically replaces the entire override layer, then notifies listeners of every flag whose + /// merged-view evaluation may have changed. Calls are serialized, so overlapping updates from + /// a source cannot interleave. + /// + public void SetOverrides(FullDataSet data) + { + lock (_lock) + { + // Computing affected flags requires snapshots of the merged view before and after the + // replacement. Skip all of that work when nothing is listening. + if (!_hasListeners()) + { + _layer.SetAll(data, out _, out _); + return; + } + + _layer.SetAll(data, out var previous, out var current); + // The replacement is in effect now. A failure to determine or deliver the change + // notifications must not reach the source, which would treat it as a failed + // update, so it is logged and the listeners miss this update. + try + { + var oldMerged = OverrideChanges.SnapshotMergedView(_base, previous); + var newMerged = OverrideChanges.SnapshotMergedView(_base, current); + + var affected = OverrideChanges.ComputeAffectedFlags(previous, current, oldMerged, newMerged, _log); + if (affected.Count > 0) + { + _log.Debug("Override update affected {0} flag(s)", affected.Count); + _notify(affected); + } + } + catch (Exception e) + { + LogHelpers.LogException(_log, "Unable to determine the flags affected by an override update", e); + } + } + } + } +} diff --git a/pkgs/sdk/server/src/LdClient.cs b/pkgs/sdk/server/src/LdClient.cs index f8b2c3e99..81281282d 100644 --- a/pkgs/sdk/server/src/LdClient.cs +++ b/pkgs/sdk/server/src/LdClient.cs @@ -3,6 +3,7 @@ using System.Linq; using System.Security.Cryptography; using System.Threading; +using System.Threading.Tasks; using LaunchDarkly.Logging; using LaunchDarkly.Sdk.Internal; using LaunchDarkly.Sdk.Server.Hooks; @@ -41,6 +42,8 @@ public sealed class LdClient : IDisposable, ILdClient // Each field changes from 0 to 1 when the matching cached-data warning logs. private int _evalCachedDataWarningLogged; private int _allFlagsStateCachedDataWarningLogged; + // Changes from 0 to 1 when the warning about an all-flags state built from overrides alone logs. + private int _allFlagsStateOverridesOnlyWarningLogged; private readonly IHookExecutor _hookExecutor; internal readonly IDataSystem _dataSystem; @@ -215,7 +218,18 @@ public LdClient(Configuration config) this.RegisterPlugins(pluginConfig.Plugins, environmentMetadata, _log); - var initTask = _dataSystem.Start(); + Task initTask; + try + { + initTask = _dataSystem.Start(); + } + catch (Exception) + { + // A component that fails to start fails the constructor. The components created so + // far are disposed first, so a failed constructor leaves nothing running. + Dispose(true); + throw; + } if (!_dataSystem.Initialized) { @@ -363,6 +377,7 @@ public FeatureFlagsState AllFlagsState(Context context, params FlagsStateOption[ return new FeatureFlagsState(false); } + var overridesOnly = false; if (!Initialized) { if (_dataSystem.Store.Initialized()) @@ -372,6 +387,12 @@ public FeatureFlagsState AllFlagsState(Context context, params FlagsStateOption[ _evalLog.Warn("AllFlagsState() called before client initialized; using last known values from data store. This message is logged once."); } } + else if (_dataSystem.OverridesConfigured) + { + // No data from LaunchDarkly is available. The store read below returns only the + // entries that the override layer holds. The result decides the state. + overridesOnly = true; + } else { _evalLog.Warn("AllFlagsState() called before client initialized; data store unavailable, returning empty state"); @@ -397,6 +418,18 @@ public FeatureFlagsState AllFlagsState(Context context, params FlagsStateOption[ LogHelpers.LogException(_log, "Exception while retrieving flags for AllFlagsState", e); return new FeatureFlagsState(false); } + if (overridesOnly) + { + if (!flags.Items.Any()) + { + _evalLog.Warn("AllFlagsState() called before client initialized; data store unavailable, returning empty state"); + return new FeatureFlagsState(false); + } + if (Interlocked.Exchange(ref _allFlagsStateOverridesOnlyWarningLogged, 1) == 0) + { + _evalLog.Warn("AllFlagsState() called before client initialized; returning only flags from the override layer. This message is logged once."); + } + } foreach (var pair in flags.Items) { if (pair.Value.Item is null || !(pair.Value.Item is FeatureFlag flag)) @@ -416,15 +449,28 @@ public FeatureFlagsState AllFlagsState(Context context, params FlagsStateOption[ e => e.FlagKey == flag.Key) .Select(p => p.PrerequisiteFlag.Key).ToList(); + var trackEvents = flag.TrackEvents || inExperiment; + var trackReason = inExperiment; + var debugEventsUntilDate = flag.DebugEventsUntilDate; + if (result.Result.Reason.OverrideAffected) + { + // A consumer of this state sends individual events according to these fields. + // An override-affected evaluation produces no individual events, so the state + // turns them off for this flag. The flag, its value, and its reason stay. + trackEvents = false; + trackReason = false; + debugEventsUntilDate = null; + } + builder.AddFlag( flag.Key, result.Result.Value, result.Result.VariationIndex, result.Result.Reason, flag.Version, - flag.TrackEvents || inExperiment, - inExperiment, - flag.DebugEventsUntilDate, + trackEvents, + trackReason, + debugEventsUntilDate, directPrerequisites); } catch (Exception e) @@ -432,7 +478,10 @@ public FeatureFlagsState AllFlagsState(Context context, params FlagsStateOption[ LogHelpers.LogException(_evalLog, string.Format("Exception caught for feature flag \"{0}\" when evaluating all flags", flag.Key), e); - EvaluationReason reason = EvaluationReason.ErrorReason(EvaluationErrorKind.Exception); + // A definition from the override layer keeps the override indicator on its error + // reason. This entry has no tracking fields, as for every error result here. + var reason = EvaluationReason.ErrorReason(EvaluationErrorKind.Exception) + .WithOverrideAffected(flag.IsOverride); builder.AddFlag(flag.Key, new EvaluationDetail(LdValue.Null, null, reason), new List()); } } @@ -454,6 +503,7 @@ public FeatureFlagsState AllFlagsState(Context context, params FlagsStateOption[ bool checkType, EventFactory eventFactory) { T defaultValueOfType = converter.ToType(defaultValue); + var noLaunchDarklyData = false; if (!Initialized) { if (_dataSystem.Store.Initialized()) @@ -463,8 +513,17 @@ public FeatureFlagsState AllFlagsState(Context context, params FlagsStateOption[ _evalLog.Warn("Flag evaluation before client initialized; using last known values from data store. This message is logged once."); } } + else if (_dataSystem.HasOverride(DataModel.Features, featureKey)) + { + // No data from LaunchDarkly is available, but the override layer holds this flag, + // and the store read below serves it. The layer can be replaced before that read. + // A miss then returns the not-ready default. + noLaunchDarklyData = true; + } else { + // A flag the layer does not hold takes the same path as without an override + // source. _evalLog.Warn("Flag evaluation before client initialized; data store unavailable, returning default value"); return (new EvaluationDetail(defaultValueOfType, null, EvaluationReason.ErrorReason(EvaluationErrorKind.ClientNotReady)), null); @@ -485,6 +544,12 @@ public FeatureFlagsState AllFlagsState(Context context, params FlagsStateOption[ featureFlag = GetFlag(featureKey); if (featureFlag == null) { + if (noLaunchDarklyData) + { + _evalLog.Warn("Flag evaluation before client initialized; data store unavailable, returning default value"); + return (new EvaluationDetail(defaultValueOfType, null, + EvaluationReason.ErrorReason(EvaluationErrorKind.ClientNotReady)), null); + } _evalLog.Info("Unknown feature flag \"{0}\"; returning default value", featureKey); _eventProcessor.RecordEvaluationEvent(eventFactory.NewUnknownFlagEvaluationEvent( @@ -520,8 +585,11 @@ public FeatureFlagsState AllFlagsState(Context context, params FlagsStateOption[ _eventProcessor.RecordEvaluationEvent(eventFactory.NewDefaultValueEvaluationEvent( featureFlag, context, defaultValue, EvaluationErrorKind.WrongType)); + // The type mismatch replaces the reason. The evaluation read the same + // definitions, so the new reason keeps the override-affected marking. return (new EvaluationDetail(defaultValueOfType, null, - EvaluationReason.ErrorReason(EvaluationErrorKind.WrongType)), featureFlag); + EvaluationReason.ErrorReason(EvaluationErrorKind.WrongType) + .WithOverrideAffected(evalDetail.Reason.OverrideAffected)), featureFlag); } returnDetail = new EvaluationDetail(converter.ToType(evalDetail.Value), evalDetail.VariationIndex, evalDetail.Reason); @@ -535,7 +603,11 @@ public FeatureFlagsState AllFlagsState(Context context, params FlagsStateOption[ LogHelpers.LogException(_evalLog, string.Format("Exception when evaluating feature flag \"{0}\"", featureKey), e); - var reason = EvaluationReason.ErrorReason(EvaluationErrorKind.Exception); + // When the definition that was being evaluated came from the override layer, the + // error reason and the event carry the override indicator, like every other result + // of that definition. + var reason = EvaluationReason.ErrorReason(EvaluationErrorKind.Exception) + .WithOverrideAffected(featureFlag != null && featureFlag.IsOverride); if (featureFlag == null) { _eventProcessor.RecordEvaluationEvent(eventFactory.NewUnknownFlagEvaluationEvent( diff --git a/pkgs/sdk/server/src/Subsystems/DataSystemConfiguration.cs b/pkgs/sdk/server/src/Subsystems/DataSystemConfiguration.cs index 16652813f..a4f154186 100644 --- a/pkgs/sdk/server/src/Subsystems/DataSystemConfiguration.cs +++ b/pkgs/sdk/server/src/Subsystems/DataSystemConfiguration.cs @@ -58,18 +58,30 @@ public enum DataStoreMode /// public DataStoreMode PersistentDataStoreMode { get; } + /// + /// An optional factory for creating the override source. This is null when no override source is + /// configured, in which case the SDK behaves exactly as it does without the flag overrides feature. + /// + /// + /// Flag overrides are currently experimental and subject to change. + /// + /// + public IComponentConfigurer OverrideSource { get; } + internal DataSystemConfiguration( IReadOnlyList> initializers, IReadOnlyList> synchronizers, IComponentConfigurer fDv1FallbackSynchronizer, IComponentConfigurer persistentStore, - DataStoreMode persistentDataStoreMode) + DataStoreMode persistentDataStoreMode, + IComponentConfigurer overrideSource) { Initializers = initializers; Synchronizers = synchronizers; FDv1FallbackSynchronizer = fDv1FallbackSynchronizer; PersistentStore = persistentStore; PersistentDataStoreMode = persistentDataStoreMode; + OverrideSource = overrideSource; } } } diff --git a/pkgs/sdk/server/src/Subsystems/IOverrideSource.cs b/pkgs/sdk/server/src/Subsystems/IOverrideSource.cs new file mode 100644 index 000000000..6839d36da --- /dev/null +++ b/pkgs/sdk/server/src/Subsystems/IOverrideSource.cs @@ -0,0 +1,87 @@ +using System; + +using static LaunchDarkly.Sdk.Server.Subsystems.DataStoreTypes; + +namespace LaunchDarkly.Sdk.Server.Subsystems +{ + /// + /// Interface for a component that supplies flag and segment overrides. Overrides take precedence + /// over data received from LaunchDarkly at evaluation time, on a per-key basis. + /// + /// + /// + /// Overrides exist for resilience during an incident. They let an operator force one or more flags + /// to a known state on a running client, whether or not the client can reach LaunchDarkly. Flags + /// not present in the override data are unaffected. + /// + /// + /// An override source is not a data source. It does not take part in the data system's initializer + /// and synchronizer pipeline, and the override layer it populates has no effect on the client's + /// initialization status, data availability, or data source status. + /// + /// + /// The SDK constructs the source from the given to + /// , + /// starts it when the client starts, and disposes it when the client is disposed. + /// + /// + /// Flag overrides are currently experimental and subject to change. + /// + /// + public interface IOverrideSource : IDisposable + { + /// + /// Starts the source. + /// + /// + /// + /// The source performs its initial load before this method returns, so that overrides present + /// when the client starts are in effect from the first evaluation. It then supplies a full + /// replacement snapshot to the sink whenever its backing data changes, until it is disposed. + /// A failed load leaves the previously supplied snapshot in place by not calling the sink. + /// + /// + /// The SDK calls this method at most once, before any call to . + /// + /// + /// receives the override snapshots + void Start(IOverrideSink sink); + } + + /// + /// Receives the contents of the SDK's override layer from an . + /// + /// + /// + /// The SDK implements this interface and passes it to . + /// Override sources call it. They do not implement it. + /// + /// + /// Flag overrides are currently experimental and subject to change. + /// + /// + public interface IOverrideSink + { + /// + /// Replaces the entire override layer with the given flags and segments. + /// + /// + /// + /// Each call is a full snapshot. Entries absent from the call are removed from the layer, and + /// an empty data set clears the layer. + /// + /// + /// The data set uses the SDK's standard data kinds, and + /// , with items produced by their + /// methods. Sources supply ordinary, fully parsed + /// definitions. The SDK itself marks the entries as overrides. + /// + /// + /// This method is safe to call from any thread. Calls are serialized by the SDK, and the new + /// layer contents are visible to evaluations when the call returns. + /// + /// + /// the complete set of override flags and segments + void SetOverrides(FullDataSet data); + } +} diff --git a/pkgs/sdk/server/test/Integrations/DataSystemBuilderOverridesTest.cs b/pkgs/sdk/server/test/Integrations/DataSystemBuilderOverridesTest.cs new file mode 100644 index 000000000..c27e872aa --- /dev/null +++ b/pkgs/sdk/server/test/Integrations/DataSystemBuilderOverridesTest.cs @@ -0,0 +1,36 @@ +using Xunit; + +namespace LaunchDarkly.Sdk.Server.Integrations +{ + public class DataSystemBuilderOverridesTest + { + [Fact] + public void OverrideSourceIsNullByDefault() + { + Assert.Null(Components.DataSystem().Default().Build().OverrideSource); + Assert.Null(Components.DataSystem().Custom().Build().OverrideSource); + } + + [Fact] + public void OverridesSetsTheOverrideSource() + { + var source = new TestOverrideSource(); + var config = Components.DataSystem().Default().Overrides(source).Build(); + Assert.Same(source, config.OverrideSource); + // The rest of the default configuration is unchanged. + Assert.Single(config.Initializers); + Assert.Equal(2, config.Synchronizers.Count); + } + + [Fact] + public void LaterCallReplacesTheOverrideSource() + { + var first = new TestOverrideSource(); + var second = new TestOverrideSource(); + var config = Components.DataSystem().Custom().Overrides(first).Overrides(second).Build(); + Assert.Same(second, config.OverrideSource); + + Assert.Null(Components.DataSystem().Custom().Overrides(first).Overrides(null).Build().OverrideSource); + } + } +} diff --git a/pkgs/sdk/server/test/Internal/Overrides/FakeReadOnlyStore.cs b/pkgs/sdk/server/test/Internal/Overrides/FakeReadOnlyStore.cs new file mode 100644 index 000000000..f8a9c924d --- /dev/null +++ b/pkgs/sdk/server/test/Internal/Overrides/FakeReadOnlyStore.cs @@ -0,0 +1,74 @@ +using System; +using System.Collections.Generic; +using LaunchDarkly.Sdk.Server.Internal.DataSystem; +using LaunchDarkly.Sdk.Server.Internal.Model; + +using static LaunchDarkly.Sdk.Server.Subsystems.DataStoreTypes; + +namespace LaunchDarkly.Sdk.Server.Internal.Overrides +{ + // A minimal read-only store for testing the overlay and the sink against arbitrary base data and + // initialization states. + internal sealed class FakeReadOnlyStore : IReadOnlyStore + { + internal readonly Dictionary Flags = new Dictionary(); + internal readonly Dictionary Segments = new Dictionary(); + internal bool IsInitialized = true; + internal Exception GetAllError; + internal int GetAllCalls; + + internal FakeReadOnlyStore WithFlags(params FeatureFlag[] flags) + { + foreach (var flag in flags) + { + Flags[flag.Key] = new ItemDescriptor(flag.Version, flag); + } + return this; + } + + internal FakeReadOnlyStore WithSegments(params Segment[] segments) + { + foreach (var segment in segments) + { + Segments[segment.Key] = new ItemDescriptor(segment.Version, segment); + } + return this; + } + + internal FakeReadOnlyStore WithDeletedFlag(string key, int version) + { + Flags[key] = ItemDescriptor.Deleted(version); + return this; + } + + private Dictionary ItemsOf(DataKind kind) + { + if (kind == DataModel.Features) + { + return Flags; + } + if (kind == DataModel.Segments) + { + return Segments; + } + return new Dictionary(); + } + + public ItemDescriptor? Get(DataKind kind, string key) => + ItemsOf(kind).TryGetValue(key, out var item) ? item : (ItemDescriptor?)null; + + public KeyedItems GetAll(DataKind kind) + { + GetAllCalls++; + if (GetAllError != null) + { + throw GetAllError; + } + return new KeyedItems(new Dictionary(ItemsOf(kind))); + } + + public bool Initialized() => IsInitialized; + + public InitMetadata GetMetadata() => null; + } +} diff --git a/pkgs/sdk/server/test/Internal/Overrides/OverrideLayerTest.cs b/pkgs/sdk/server/test/Internal/Overrides/OverrideLayerTest.cs new file mode 100644 index 000000000..cf6b2f673 --- /dev/null +++ b/pkgs/sdk/server/test/Internal/Overrides/OverrideLayerTest.cs @@ -0,0 +1,146 @@ +using System.Collections.Generic; +using LaunchDarkly.Sdk.Server.Internal.Model; +using Xunit; + +using static LaunchDarkly.Sdk.Server.Subsystems.DataStoreTypes; + +namespace LaunchDarkly.Sdk.Server.Internal.Overrides +{ + public class OverrideLayerTest + { + internal static FullDataSet DataSetOf(IEnumerable flags, IEnumerable segments) + { + var builder = new DataSetBuilder(); + foreach (var flag in flags) + { + builder.Flags(flag); + } + foreach (var segment in segments) + { + builder.Segments(segment); + } + return builder.Build(); + } + + internal static FullDataSet FlagsOnly(params FeatureFlag[] flags) => + DataSetOf(flags, new Segment[0]); + + internal static FullDataSet SegmentsOnly(params Segment[] segments) => + DataSetOf(new FeatureFlag[0], segments); + + private static FeatureFlag RequireFlag(ItemDescriptor? item) + { + Assert.True(item.HasValue); + return Assert.IsType(item.Value.Item); + } + + [Fact] + public void LayerStartsEmpty() + { + var layer = new OverrideLayer(); + Assert.True(layer.IsEmpty); + Assert.Null(layer.Get(DataModel.Features, "flag1")); + Assert.Empty(layer.All(DataModel.Features)); + Assert.Empty(layer.All(DataModel.Segments)); + } + + [Fact] + public void SetAllMarksCopiesWithoutMutatingTheSource() + { + var layer = new OverrideLayer(); + var flag = new FeatureFlagBuilder("flag1").Version(2).Build(); + var segment = new SegmentBuilder("segment1").Version(3).Build(); + + layer.SetAll(DataSetOf(new[] { flag }, new[] { segment }), out _, out _); + + Assert.False(flag.IsOverride); + Assert.False(segment.IsOverride); + + var storedFlag = layer.Get(DataModel.Features, "flag1"); + Assert.True(RequireFlag(storedFlag).IsOverride); + Assert.Equal(2, storedFlag.Value.Version); + + var storedSegment = layer.Get(DataModel.Segments, "segment1"); + Assert.True(storedSegment.HasValue); + Assert.True(Assert.IsType(storedSegment.Value.Item).IsOverride); + Assert.Equal(3, storedSegment.Value.Version); + } + + [Fact] + public void MarkedCopySharesImmutablePartsAndKeepsContent() + { + var flag = new FeatureFlagBuilder("flag1").Version(2).On(true).FallthroughVariation(1) + .Variations("a", "b") + .Rules(new RuleBuilder().Id("r").Variation(1).Clauses(ClauseBuilder.ShouldMatchUser(Context.New("u"))).Build()) + .Build(); + + var marked = RequireFlag(OverrideLayer.MarkedCopy(new ItemDescriptor(2, flag))); + + Assert.True(marked.IsOverride); + Assert.Same(flag.Rules, marked.Rules); + Assert.Same(flag.Prerequisites, marked.Prerequisites); + Assert.Equal(flag.ToJsonString(), marked.ToJsonString()); + } + + [Fact] + public void MarkedCopyLeavesDeletedItemsAndOtherTypesAlone() + { + var deleted = ItemDescriptor.Deleted(5); + var copy = OverrideLayer.MarkedCopy(deleted); + Assert.Null(copy.Item); + Assert.Equal(5, copy.Version); + + var other = new ItemDescriptor(1, "not a model object"); + Assert.Same(other.Item, OverrideLayer.MarkedCopy(other).Item); + } + + [Fact] + public void ReplacementSemantics() + { + var layer = new OverrideLayer(); + + layer.SetAll(FlagsOnly(new FeatureFlagBuilder("flag1").Build()), out var previous, out var current); + Assert.False(layer.IsEmpty); + Assert.NotNull(layer.Get(DataModel.Features, "flag1")); + Assert.Empty(previous); + Assert.Single(current[DataModel.Features]); + + // A replacement is a full snapshot: entries absent from it are removed. + layer.SetAll(FlagsOnly(new FeatureFlagBuilder("flag2").Build()), out previous, out current); + Assert.Null(layer.Get(DataModel.Features, "flag1")); + Assert.NotNull(layer.Get(DataModel.Features, "flag2")); + Assert.True(previous[DataModel.Features].ContainsKey("flag1")); + Assert.True(current[DataModel.Features].ContainsKey("flag2")); + + layer.SetAll(FullDataSet.Empty(), out _, out _); + Assert.True(layer.IsEmpty); + Assert.Null(layer.Get(DataModel.Features, "flag2")); + } + + [Fact] + public void EmptyCollectionsCountAsEmpty() + { + var layer = new OverrideLayer(); + layer.SetAll(DataSetBuilder.Empty, out _, out _); + Assert.True(layer.IsEmpty); + } + + [Fact] + public void AllReturnsTheSnapshotOfOneKind() + { + var layer = new OverrideLayer(); + layer.SetAll(DataSetOf( + new[] { new FeatureFlagBuilder("flag1").Build(), new FeatureFlagBuilder("flag2").Build() }, + new[] { new SegmentBuilder("segment1").Build() }), out _, out _); + + var flags = layer.All(DataModel.Features); + Assert.Equal(2, flags.Count); + Assert.True(RequireFlag(flags["flag1"]).IsOverride); + Assert.Single(layer.All(DataModel.Segments)); + + // The snapshot is immutable: a later replacement does not change it. + layer.SetAll(FullDataSet.Empty(), out _, out _); + Assert.Equal(2, flags.Count); + } + } +} diff --git a/pkgs/sdk/server/test/Internal/Overrides/OverrideOverlayTest.cs b/pkgs/sdk/server/test/Internal/Overrides/OverrideOverlayTest.cs new file mode 100644 index 000000000..aeabd314c --- /dev/null +++ b/pkgs/sdk/server/test/Internal/Overrides/OverrideOverlayTest.cs @@ -0,0 +1,131 @@ +using System; +using System.Linq; +using LaunchDarkly.Sdk.Server.Internal.Model; +using Xunit; + +using static LaunchDarkly.Sdk.Server.Subsystems.DataStoreTypes; + +namespace LaunchDarkly.Sdk.Server.Internal.Overrides +{ + public class OverrideOverlayTest + { + private static FeatureFlag RequireFlag(ItemDescriptor? item) + { + Assert.True(item.HasValue); + return Assert.IsType(item.Value.Item); + } + + [Fact] + public void GetPrefersTheOverrideEntry() + { + var baseStore = new FakeReadOnlyStore().WithFlags( + new FeatureFlagBuilder("both").Version(1).Build(), + new FeatureFlagBuilder("base-only").Version(1).Build()); + var layer = new OverrideLayer(); + layer.SetAll(OverrideLayerTest.FlagsOnly( + new FeatureFlagBuilder("both").Version(99).Build(), + new FeatureFlagBuilder("override-only").Version(1).Build()), out _, out _); + var overlay = new OverrideOverlay(baseStore, layer); + + var both = overlay.Get(DataModel.Features, "both"); + Assert.Equal(99, both.Value.Version); + Assert.True(RequireFlag(both).IsOverride); + + Assert.False(RequireFlag(overlay.Get(DataModel.Features, "base-only")).IsOverride); + Assert.True(RequireFlag(overlay.Get(DataModel.Features, "override-only")).IsOverride); + Assert.Null(overlay.Get(DataModel.Features, "nowhere")); + } + + [Fact] + public void GetServesOverridesFromAnUninitializedBase() + { + var baseStore = new FakeReadOnlyStore { IsInitialized = false }; + var layer = new OverrideLayer(); + layer.SetAll(OverrideLayerTest.FlagsOnly(new FeatureFlagBuilder("flag1").Build()), out _, out _); + var overlay = new OverrideOverlay(baseStore, layer); + + Assert.True(RequireFlag(overlay.Get(DataModel.Features, "flag1")).IsOverride); + Assert.False(overlay.Initialized()); + } + + [Fact] + public void InitializedAndMetadataDelegateToTheBase() + { + var baseStore = new FakeReadOnlyStore { IsInitialized = true }; + var overlay = new OverrideOverlay(baseStore, new OverrideLayer()); + Assert.True(overlay.Initialized()); + baseStore.IsInitialized = false; + Assert.False(overlay.Initialized()); + Assert.Null(overlay.GetMetadata()); + } + + [Fact] + public void GetAllReturnsTheUnionWithOverridesWinning() + { + var baseStore = new FakeReadOnlyStore() + .WithFlags( + new FeatureFlagBuilder("both").Version(1).Build(), + new FeatureFlagBuilder("base-only").Version(1).Build()) + .WithDeletedFlag("tombstone", 5); + var layer = new OverrideLayer(); + layer.SetAll(OverrideLayerTest.FlagsOnly( + new FeatureFlagBuilder("both").Version(99).Build(), + new FeatureFlagBuilder("tombstone").Version(1).Build(), + new FeatureFlagBuilder("override-only").Version(1).Build()), out _, out _); + var overlay = new OverrideOverlay(baseStore, layer); + + var byKey = overlay.GetAll(DataModel.Features).Items.ToDictionary(kv => kv.Key, kv => kv.Value); + + Assert.Equal(4, byKey.Count); + Assert.Equal(99, byKey["both"].Version); + Assert.True(RequireFlag(byKey["both"]).IsOverride); + Assert.False(RequireFlag(byKey["base-only"]).IsOverride); + Assert.NotNull(byKey["tombstone"].Item); + Assert.True(RequireFlag(byKey["override-only"]).IsOverride); + } + + [Fact] + public void GetAllWithAnEmptyLayerIsAPassthrough() + { + var baseStore = new FakeReadOnlyStore().WithFlags(new FeatureFlagBuilder("flag1").Build()); + var overlay = new OverrideOverlay(baseStore, new OverrideLayer()); + + Assert.Single(overlay.GetAll(DataModel.Features).Items); + + baseStore.GetAllError = new InvalidOperationException("sinkhole"); + Assert.Throws(() => overlay.GetAll(DataModel.Features)); + } + + [Fact] + public void GetAllServesOverridesWhenTheBaseFails() + { + var baseStore = new FakeReadOnlyStore { GetAllError = new InvalidOperationException("sinkhole") }; + var layer = new OverrideLayer(); + layer.SetAll(OverrideLayerTest.FlagsOnly( + new FeatureFlagBuilder("override-1").Version(1).Build(), + new FeatureFlagBuilder("override-2").Version(2).Build()), out _, out _); + var overlay = new OverrideOverlay(baseStore, layer); + + var items = overlay.GetAll(DataModel.Features).Items.ToList(); + + Assert.Equal(2, items.Count); + Assert.All(items, kv => Assert.True(RequireFlag(kv.Value).IsOverride)); + Assert.Contains(items, kv => kv.Key == "override-1"); + Assert.Contains(items, kv => kv.Key == "override-2"); + } + + [Fact] + public void GetAllOfSegmentsAppliesTheSamePrecedence() + { + var baseStore = new FakeReadOnlyStore().WithSegments(new SegmentBuilder("s1").Version(1).Build()); + var layer = new OverrideLayer(); + layer.SetAll(OverrideLayerTest.SegmentsOnly(new SegmentBuilder("s1").Version(9).Build()), out _, out _); + var overlay = new OverrideOverlay(baseStore, layer); + + var items = overlay.GetAll(DataModel.Segments).Items.ToList(); + Assert.Single(items); + Assert.Equal(9, items[0].Value.Version); + Assert.True(Assert.IsType(items[0].Value.Item).IsOverride); + } + } +} diff --git a/pkgs/sdk/server/test/Internal/Overrides/OverrideSinkTest.cs b/pkgs/sdk/server/test/Internal/Overrides/OverrideSinkTest.cs new file mode 100644 index 000000000..4abcb5542 --- /dev/null +++ b/pkgs/sdk/server/test/Internal/Overrides/OverrideSinkTest.cs @@ -0,0 +1,192 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using LaunchDarkly.Logging; +using LaunchDarkly.Sdk.Server.Internal.Model; +using Xunit; + +using static LaunchDarkly.Sdk.Server.Subsystems.DataStoreTypes; + +namespace LaunchDarkly.Sdk.Server.Internal.Overrides +{ + public class OverrideSinkTest + { + private readonly List _notified = new List(); + private bool _listening = true; + + private OverrideSink MakeSink(FakeReadOnlyStore baseStore, out OverrideLayer layer) + { + layer = new OverrideLayer(); + return new OverrideSink(layer, baseStore, keys => _notified.AddRange(keys), () => _listening, + Logs.None.Logger("")); + } + + private string[] TakeNotified() + { + var result = _notified.OrderBy(k => k).ToArray(); + _notified.Clear(); + return result; + } + + [Fact] + public void NotifiesOnAddChangeAndRemove() + { + var baseStore = new FakeReadOnlyStore().WithFlags(new FeatureFlagBuilder("flag1").Version(1).Build()); + var sink = MakeSink(baseStore, out var layer); + + // Adding an override is a change even though flag1 also exists in base data. + sink.SetOverrides(OverrideLayerTest.FlagsOnly( + new FeatureFlagBuilder("flag1").Version(1).Build(), + new FeatureFlagBuilder("flag2").Version(1).Build())); + Assert.Equal(new[] { "flag1", "flag2" }, TakeNotified()); + + // An identical replacement, rebuilt from scratch, changes nothing. + sink.SetOverrides(OverrideLayerTest.FlagsOnly( + new FeatureFlagBuilder("flag1").Version(1).Build(), + new FeatureFlagBuilder("flag2").Version(1).Build())); + Assert.Empty(TakeNotified()); + + // Changing one entry notifies only that entry. + sink.SetOverrides(OverrideLayerTest.FlagsOnly( + new FeatureFlagBuilder("flag1").Version(1).Build(), + new FeatureFlagBuilder("flag2").Version(2).Build())); + Assert.Equal(new[] { "flag2" }, TakeNotified()); + + // A content change at the same version is still a change. + sink.SetOverrides(OverrideLayerTest.FlagsOnly( + new FeatureFlagBuilder("flag1").Version(1).On(true).Build(), + new FeatureFlagBuilder("flag2").Version(2).Build())); + Assert.Equal(new[] { "flag1" }, TakeNotified()); + + // Removing overrides notifies them: flag1 reverts to base data, flag2 to not found. + sink.SetOverrides(FullDataSet.Empty()); + Assert.Equal(new[] { "flag1", "flag2" }, TakeNotified()); + Assert.True(layer.IsEmpty); + } + + [Fact] + public void SegmentOverrideFansOutToDependentFlags() + { + var baseStore = new FakeReadOnlyStore() + .WithFlags( + new FeatureFlagBuilder("dependent").Version(1).BooleanMatchingSegment("segment1").Build(), + new FeatureFlagBuilder("unrelated").Version(1).Build()) + .WithSegments(new SegmentBuilder("segment1").Version(1).Build()); + var sink = MakeSink(baseStore, out _); + + sink.SetOverrides(OverrideLayerTest.SegmentsOnly(new SegmentBuilder("segment1").Version(99).Build())); + + // The segment itself is not a flag, so only the dependent flag is notified. + Assert.Equal(new[] { "dependent" }, TakeNotified()); + } + + [Fact] + public void PrerequisiteFanOutUsesOldAndNewViews() + { + // The override for "parent" declares a prerequisite on "prereq". The base definition of + // "parent" has no prerequisites. When the override is removed, the dependency edge only + // exists in the old merged view. "parent" must still be notified when "prereq" changes in + // the same replacement. + var baseStore = new FakeReadOnlyStore().WithFlags( + new FeatureFlagBuilder("parent").Version(1).Build(), + new FeatureFlagBuilder("prereq").Version(1).Build()); + var sink = MakeSink(baseStore, out _); + + sink.SetOverrides(OverrideLayerTest.FlagsOnly( + new FeatureFlagBuilder("parent").Version(1).Prerequisites(new Prerequisite("prereq", 0)).Build())); + Assert.Equal(new[] { "parent" }, TakeNotified()); + + // Replace the layer with an override of the prerequisite only. The "parent" override is + // removed (a change) and "prereq" is added (a change). Fan-out through the old view's edge + // also reaches "parent". + sink.SetOverrides(OverrideLayerTest.FlagsOnly(new FeatureFlagBuilder("prereq").Version(99).Build())); + Assert.Equal(new[] { "parent", "prereq" }, TakeNotified()); + + // Now only "prereq" is overridden and nothing depends on it in the new view either. + sink.SetOverrides(OverrideLayerTest.FlagsOnly(new FeatureFlagBuilder("prereq").Version(100).Build())); + Assert.Equal(new[] { "prereq" }, TakeNotified()); + } + + [Fact] + public void SkipsDiffWorkWithoutListeners() + { + var baseStore = new FakeReadOnlyStore { GetAllError = new InvalidOperationException("GetAll should not be called") }; + var sink = MakeSink(baseStore, out var layer); + _listening = false; + + sink.SetOverrides(OverrideLayerTest.FlagsOnly(new FeatureFlagBuilder("flag1").Build())); + + Assert.NotNull(layer.Get(DataModel.Features, "flag1")); + Assert.Empty(_notified); + Assert.Equal(0, baseStore.GetAllCalls); + } + + [Fact] + public void ToleratesBaseReadFailure() + { + var baseStore = new FakeReadOnlyStore { GetAllError = new InvalidOperationException("sinkhole") }; + var sink = MakeSink(baseStore, out _); + + // Fan-out degrades, but the directly changed flags are still notified. + sink.SetOverrides(OverrideLayerTest.FlagsOnly(new FeatureFlagBuilder("flag1").Build())); + Assert.Equal(new[] { "flag1" }, TakeNotified()); + } + + [Fact] + public void MalformedDefinitionsDoNotStopNotifications() + { + // A prerequisite without a key and a segment match on a value that is not a string have + // no usable dependencies. The layer holds the entries, every changed key is notified, and + // each definition whose dependencies could not be computed is logged. + var logCapture = Logs.Capture(); + var layer = new OverrideLayer(); + var sink = new OverrideSink(layer, new FakeReadOnlyStore(), keys => _notified.AddRange(keys), () => true, + logCapture.Logger("")); + var keylessPrerequisite = new FeatureFlagBuilder("keyless-prerequisite").Version(1) + .Prerequisites(new Prerequisite(null, 0)).Build(); + var numericSegmentMatch = new FeatureFlagBuilder("numeric-segment-match").Version(1) + .BooleanWithClauses(new ClauseBuilder().Op(Operator.SegmentMatch).Values(123).Build()).Build(); + + sink.SetOverrides(OverrideLayerTest.FlagsOnly( + new FeatureFlagBuilder("good").Version(1).Build(), keylessPrerequisite, numericSegmentMatch)); + + Assert.Equal(new[] { "good", "keyless-prerequisite", "numeric-segment-match" }, TakeNotified()); + Assert.NotNull(layer.Get(DataModel.Features, "keyless-prerequisite")); + Assert.NotNull(layer.Get(DataModel.Features, "numeric-segment-match")); + Assert.True(logCapture.HasMessageWithRegex(LogLevel.Warn, "dependencies of features \"keyless-prerequisite\"")); + Assert.True(logCapture.HasMessageWithRegex(LogLevel.Warn, "dependencies of features \"numeric-segment-match\"")); + } + + [Fact] + public void NotificationFailureDoesNotPropagateToTheSource() + { + var logCapture = Logs.Capture(); + var layer = new OverrideLayer(); + var sink = new OverrideSink(layer, new FakeReadOnlyStore(), + keys => throw new InvalidOperationException("listener failed"), () => true, logCapture.Logger("")); + + var exception = Record.Exception(() => + sink.SetOverrides(OverrideLayerTest.FlagsOnly(new FeatureFlagBuilder("flag1").Build()))); + + // The replacement is in effect, and the failure is logged instead of reported to the source. + Assert.Null(exception); + Assert.NotNull(layer.Get(DataModel.Features, "flag1")); + Assert.True(logCapture.HasMessageWithRegex(LogLevel.Error, "Unable to determine the flags affected")); + } + + [Fact] + public void LayerIsReplacedBeforeListenersAreNotified() + { + var baseStore = new FakeReadOnlyStore(); + OverrideLayer layer = null; + var seenInLayerAtNotify = new List(); + var sink = new OverrideSink(layer = new OverrideLayer(), baseStore, + keys => seenInLayerAtNotify.Add(layer.Get(DataModel.Features, "flag1").HasValue), + () => true, Logs.None.Logger("")); + + sink.SetOverrides(OverrideLayerTest.FlagsOnly(new FeatureFlagBuilder("flag1").Build())); + + Assert.Equal(new[] { true }, seenInLayerAtNotify); + } + } +} diff --git a/pkgs/sdk/server/test/LaunchDarkly.ServerSdk.Tests.csproj b/pkgs/sdk/server/test/LaunchDarkly.ServerSdk.Tests.csproj index 228703aae..765b4eb66 100644 --- a/pkgs/sdk/server/test/LaunchDarkly.ServerSdk.Tests.csproj +++ b/pkgs/sdk/server/test/LaunchDarkly.ServerSdk.Tests.csproj @@ -39,6 +39,9 @@ Always + + Always + Always diff --git a/pkgs/sdk/server/test/LdClientOverrideVectorsTest.cs b/pkgs/sdk/server/test/LdClientOverrideVectorsTest.cs new file mode 100644 index 000000000..6cdf2d524 --- /dev/null +++ b/pkgs/sdk/server/test/LdClientOverrideVectorsTest.cs @@ -0,0 +1,143 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using LaunchDarkly.Sdk.Json; +using LaunchDarkly.Sdk.Server.Internal.Model; +using Xunit; +using Xunit.Abstractions; + +using static LaunchDarkly.Sdk.Server.Subsystems.DataStoreTypes; + +namespace LaunchDarkly.Sdk.Server +{ + // Runs the OVERRIDE specification's test vectors. Each vector sets up LaunchDarkly data, an + // override layer, and an initialization state, evaluates one flag through the full client stack, + // and checks the value, the variation index, and the reason. + public class LdClientOverrideVectorsTest : BaseTest + { + private static readonly string VectorsPath = TestUtils.TestFilePath("override-vectors.json"); + + // The vectors' semantics are versioned. A schema change means this runner needs review. + private const string SupportedSchemaVersion = "0.4.0"; + + public LdClientOverrideVectorsTest(ITestOutputHelper testOutput) : base(testOutput) { } + + private static LdValue LoadVectorFile() => LdValue.Parse(File.ReadAllText(VectorsPath)); + + public static IEnumerable Vectors() + { + var file = LoadVectorFile(); + var vectors = file.Get("vectors").List; + for (var i = 0; i < vectors.Count; i++) + { + yield return new object[] { i, vectors[i].Get("group").AsString + ": " + vectors[i].Get("description").AsString }; + } + } + + [Fact] + public void VectorFileHasTheSupportedSchema() + { + var file = LoadVectorFile(); + Assert.Equal(SupportedSchemaVersion, file.Get("schemaVersion").AsString); + Assert.NotEmpty(file.Get("vectors").List); + } + + [Theory] + [MemberData(nameof(Vectors))] + public void Vector(int index, string description) + { + var vector = LoadVectorFile().Get("vectors").List[index]; + Assert.NotNull(description); + + var launchDarklyData = vector.Get("launchDarklyData"); + var initialized = launchDarklyData.Get("initialized").AsBool; + var ldDataSet = DataSetFrom(launchDarklyData.Get("flags"), launchDarklyData.Get("segments"), LdValue.Null); + var overrides = vector.Get("overrides"); + var overrideDataSet = DataSetFrom(overrides.Get("flags"), overrides.Get("segments"), overrides.Get("flagValues")); + + var source = new TestOverrideSource(overrideDataSet); + var dataSystem = Components.DataSystem().Custom().Overrides(source); + var config = BasicConfig(); + if (initialized) + { + dataSystem.Synchronizers(MockComponents.MockDataSourceWithData(ldDataSet)); + config.StartWaitTime(TimeSpan.FromSeconds(5)); + } + else + { + // With no data source at all, the data system would report cached data as available. + // A synchronizer that never delivers anything keeps the client uninitialized. + dataSystem.Synchronizers(MockComponents.MockDataSourceThatNeverStarts()); + } + config.DataSystem(dataSystem); + + using (var client = new LdClient(config.Build())) + { + Assert.Equal(initialized, client.Initialized); + + var evaluate = vector.Get("evaluate"); + var evalContext = LdJsonSerialization.DeserializeObject(evaluate.Get("context").ToJsonString()); + var detail = client.JsonVariationDetail(evaluate.Get("flagKey").AsString, evalContext, evaluate.Get("defaultValue")); + + var expect = vector.Get("expect"); + Assert.Equal(expect.Get("value"), detail.Value); + var expectedVariation = expect.Get("variationIndex"); + if (expectedVariation.IsNull) + { + Assert.Null(detail.VariationIndex); + } + else + { + Assert.Equal(expectedVariation.AsInt, detail.VariationIndex); + } + AssertReason(expect.Get("reason"), detail.Reason); + } + } + + // Compares the actual reason against only the fields present in the expected reason. The + // override-affected indicator collapses tri-state: an expected reason that omits it requires + // the actual reason to report false, which is never serialized, or omit it. + private static void AssertReason(LdValue expected, EvaluationReason actual) + { + var actualJson = LdValue.Parse(LdJsonSerialization.SerializeObject(actual)); + foreach (var field in expected.Dictionary) + { + Assert.True(field.Value.Equals(actualJson.Get(field.Key)), + "reason field " + field.Key + ": expected " + field.Value + " but was " + actualJson.Get(field.Key)); + } + if (expected.Get("overrideAffected").IsNull) + { + var actualMarker = actualJson.Get("overrideAffected"); + Assert.True(actualMarker.IsNull || !actualMarker.AsBool, "overrideAffected must be false or omitted"); + Assert.False(actual.OverrideAffected); + } + } + + private static FullDataSet DataSetFrom(LdValue flags, LdValue segments, LdValue flagValues) + { + var flagItems = new List>(); + foreach (var kv in flags.Dictionary) + { + flagItems.Add(new KeyValuePair(kv.Key, DataModel.Features.Deserialize(kv.Value.ToJsonString()))); + } + foreach (var kv in flagValues.Dictionary) + { + // A value-only entry behaves like a flag that is on and serves its single value by + // fallthrough. + var flag = new FeatureFlagBuilder(kv.Key).On(true).FallthroughVariation(0).Variations(kv.Value).Build(); + flagItems.Add(new KeyValuePair(kv.Key, new ItemDescriptor(flag.Version, flag))); + } + var segmentItems = new List>(); + foreach (var kv in segments.Dictionary) + { + segmentItems.Add(new KeyValuePair(kv.Key, DataModel.Segments.Deserialize(kv.Value.ToJsonString()))); + } + return new FullDataSet(new[] + { + new KeyValuePair>(DataModel.Features, new KeyedItems(flagItems)), + new KeyValuePair>(DataModel.Segments, new KeyedItems(segmentItems)) + }); + } + } +} diff --git a/pkgs/sdk/server/test/LdClientOverridesTest.cs b/pkgs/sdk/server/test/LdClientOverridesTest.cs new file mode 100644 index 000000000..6b2329d68 --- /dev/null +++ b/pkgs/sdk/server/test/LdClientOverridesTest.cs @@ -0,0 +1,505 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using LaunchDarkly.Logging; +using LaunchDarkly.Sdk.Json; +using LaunchDarkly.Sdk.Server.Interfaces; +using LaunchDarkly.Sdk.Server.Internal.Evaluation; +using LaunchDarkly.Sdk.Server.Internal.Model; +using LaunchDarkly.Sdk.Server.Subsystems; +using LaunchDarkly.TestHelpers; +using Xunit; +using Xunit.Abstractions; + +using static LaunchDarkly.Sdk.Server.Subsystems.DataStoreTypes; + +namespace LaunchDarkly.Sdk.Server +{ + // Client-level behavior of the override layer: precedence over LaunchDarkly data, serving + // overrides before the client has LaunchDarkly data, the all-flags state, flag change + // notifications, and the source lifecycle. + public class LdClientOverridesTest : BaseTest + { + private static readonly Context context = Context.New("userkey"); + + public LdClientOverridesTest(ITestOutputHelper testOutput) : base(testOutput) { } + + internal static FullDataSet FlagsOnly(params FeatureFlag[] flags) + { + var builder = new DataSetBuilder(); + foreach (var flag in flags) + { + builder.Flags(flag); + } + return builder.Build(); + } + + internal static FeatureFlag SingleValueFlag(string key, LdValue value) => + new FeatureFlagBuilder(key).On(true).FallthroughVariation(0).Variations(value).Build(); + + // A client whose data system can never obtain LaunchDarkly data. + private LdClient MakeUninitializedClient(TestOverrideSource source, IEventProcessor events = null) + { + var config = BasicConfig() + .DataSystem(Components.DataSystem().Custom() + .Synchronizers(MockComponents.MockDataSourceThatNeverStarts()) + .Overrides(source)); + if (events != null) + { + config.Events(events.AsSingletonFactory()); + } + var client = new LdClient(config.Build()); + Assert.False(client.Initialized); + return client; + } + + // A client that has initialized with the given LaunchDarkly data. + private LdClient MakeInitializedClient(FullDataSet launchDarklyData, TestOverrideSource source, + IEventProcessor events = null) + { + var config = BasicConfig() + .StartWaitTime(TimeSpan.FromSeconds(5)) + .DataSystem(Components.DataSystem().Custom() + .Synchronizers(MockComponents.MockDataSourceWithData(launchDarklyData)) + .Overrides(source)); + if (events != null) + { + config.Events(events.AsSingletonFactory()); + } + var client = new LdClient(config.Build()); + Assert.True(client.Initialized); + return client; + } + + [Fact] + public void OverrideIsServedWhenClientIsNotInitialized() + { + var source = new TestOverrideSource(FlagsOnly(SingleValueFlag("overridden-flag", LdValue.Of(true)))); + using (var client = MakeUninitializedClient(source)) + { + var detail = client.BoolVariationDetail("overridden-flag", context, false); + Assert.True(detail.Value); + Assert.Equal(0, detail.VariationIndex); + Assert.Equal(EvaluationReasonKind.Fallthrough, detail.Reason.Kind); + Assert.True(detail.Reason.OverrideAffected); + } + } + + [Fact] + public void NonOverriddenFlagStillShortCircuitsWhenClientIsNotInitialized() + { + var source = new TestOverrideSource(FlagsOnly(SingleValueFlag("overridden-flag", LdValue.Of(true)))); + using (var client = MakeUninitializedClient(source)) + { + var detail = client.BoolVariationDetail("other-flag", context, false); + Assert.False(detail.Value); + Assert.Null(detail.VariationIndex); + Assert.Equal(EvaluationReason.ErrorReason(EvaluationErrorKind.ClientNotReady), detail.Reason); + Assert.False(detail.Reason.OverrideAffected); + } + } + + [Fact] + public void OverrideRemovalRestoresShortCircuit() + { + var source = new TestOverrideSource(FlagsOnly(SingleValueFlag("overridden-flag", LdValue.Of(true)))); + using (var client = MakeUninitializedClient(source)) + { + Assert.True(client.BoolVariation("overridden-flag", context, false)); + + source.SetOverrides(FullDataSet.Empty()); + + var detail = client.BoolVariationDetail("overridden-flag", context, false); + Assert.False(detail.Value); + Assert.Equal(EvaluationReason.ErrorReason(EvaluationErrorKind.ClientNotReady), detail.Reason); + } + } + + [Fact] + public void OverrideAddedLaterIsServedWithoutRestart() + { + var source = new TestOverrideSource(); + using (var client = MakeUninitializedClient(source)) + { + Assert.False(client.BoolVariation("overridden-flag", context, false)); + + source.SetOverrides(FlagsOnly(SingleValueFlag("overridden-flag", LdValue.Of(true)))); + + Assert.True(client.BoolVariation("overridden-flag", context, false)); + } + } + + [Fact] + public void OverridesDoNotAffectInitializationOrDataSourceStatus() + { + var source = new TestOverrideSource(FlagsOnly(SingleValueFlag("overridden-flag", LdValue.Of(true)))); + using (var client = MakeUninitializedClient(source)) + { + Assert.False(client.Initialized); + Assert.Equal(DataSourceState.Initializing, client.DataSourceStatusProvider.Status.State); + } + } + + [Fact] + public void OverrideTakesPrecedenceOverLaunchDarklyData() + { + var ldFlag = new FeatureFlagBuilder("flag").Version(100).OffWithValue(LdValue.Of("ld-value")).Build(); + var ldOther = new FeatureFlagBuilder("other").Version(100).OffWithValue(LdValue.Of("other-value")).Build(); + var source = new TestOverrideSource(FlagsOnly(SingleValueFlag("flag", LdValue.Of("override-value")))); + using (var client = MakeInitializedClient(FlagsOnly(ldFlag, ldOther), source)) + { + var detail = client.StringVariationDetail("flag", context, "default"); + Assert.Equal("override-value", detail.Value); + Assert.True(detail.Reason.OverrideAffected); + + var otherDetail = client.StringVariationDetail("other", context, "default"); + Assert.Equal("other-value", otherDetail.Value); + Assert.False(otherDetail.Reason.OverrideAffected); + + // Removing the override returns the flag to LaunchDarkly data. + source.SetOverrides(FullDataSet.Empty()); + detail = client.StringVariationDetail("flag", context, "default"); + Assert.Equal("ld-value", detail.Value); + Assert.False(detail.Reason.OverrideAffected); + } + } + + [Fact] + public void OverriddenPrerequisiteMarksTheDependentEvaluation() + { + // The LaunchDarkly copy of the prerequisite is off, so the dependent flag passes only + // through the override. + var ldPrereq = new FeatureFlagBuilder("prereq").Version(1).On(false).OffVariation(0) + .Variations(false, true).Build(); + var dependent = new FeatureFlagBuilder("dependent").Version(1).On(true).OffVariation(0) + .FallthroughVariation(1).Variations(false, true) + .Prerequisites(new Prerequisite("prereq", 1)).Build(); + var overridePrereq = new FeatureFlagBuilder("prereq").Version(2).On(true).OffVariation(0) + .FallthroughVariation(1).Variations(false, true).Build(); + var source = new TestOverrideSource(FlagsOnly(overridePrereq)); + using (var client = MakeInitializedClient(FlagsOnly(ldPrereq, dependent), source)) + { + var detail = client.BoolVariationDetail("dependent", context, false); + Assert.True(detail.Value); + Assert.Equal(EvaluationReasonKind.Fallthrough, detail.Reason.Kind); + Assert.True(detail.Reason.OverrideAffected); + } + } + + [Fact] + public void WrongTypeResultOfOverriddenFlagStaysMarked() + { + var source = new TestOverrideSource(FlagsOnly(SingleValueFlag("overridden-flag", LdValue.Of("not-a-bool")))); + using (var client = MakeUninitializedClient(source)) + { + var detail = client.BoolVariationDetail("overridden-flag", context, false); + Assert.False(detail.Value); + Assert.Null(detail.VariationIndex); + Assert.Equal(EvaluationReasonKind.Error, detail.Reason.Kind); + Assert.Equal(EvaluationErrorKind.WrongType, detail.Reason.ErrorKind); + Assert.True(detail.Reason.OverrideAffected); + Assert.Equal(LdValue.Parse(@"{""kind"":""ERROR"",""errorKind"":""WRONG_TYPE"",""overrideAffected"":true}"), + LdValue.Parse(LdJsonSerialization.SerializeObject(detail.Reason))); + } + } + + [Fact] + public void AllFlagsStateContainsOnlyOverridesWhenClientIsNotInitialized() + { + var source = new TestOverrideSource(FlagsOnly(SingleValueFlag("overridden-flag", LdValue.Of(true)))); + using (var client = MakeUninitializedClient(source)) + { + var state = client.AllFlagsState(context); + Assert.True(state.Valid); + var values = state.ToValuesJsonMap(); + Assert.Single(values); + Assert.Equal(LdValue.Of(true), values["overridden-flag"]); + } + } + + [Fact] + public void AllFlagsStateOverridesOnlyWarningIsLoggedOnce() + { + var source = new TestOverrideSource(FlagsOnly(SingleValueFlag("overridden-flag", LdValue.Of(true)))); + using (var client = MakeUninitializedClient(source)) + { + Assert.True(client.AllFlagsState(context).Valid); + Assert.True(client.AllFlagsState(context).Valid); + + var matching = LogCapture.GetMessages().Where(m => + m.Level == LogLevel.Warn && m.Text.Contains("returning only flags from the override layer")).ToList(); + Assert.Single(matching); + } + } + + [Fact] + public void AllFlagsStateIsInvalidWhenNotInitializedAndOverrideLayerIsEmpty() + { + var source = new TestOverrideSource(); + using (var client = MakeUninitializedClient(source)) + { + var state = client.AllFlagsState(context); + Assert.False(state.Valid); + Assert.Empty(state.ToValuesJsonMap()); + } + } + + [Fact] + public void AllFlagsStateTurnsOffEventTrackingForOverrideAffectedFlags() + { + var debugUntil = UnixMillisecondTime.Now.PlusMillis(100000); + var plainTracked = new FeatureFlagBuilder("plain-tracked").Version(1).OffWithValue(LdValue.Of(true)) + .TrackEvents(true).DebugEventsUntilDate(debugUntil).Build(); + var dependentTracked = new FeatureFlagBuilder("dependent-tracked").Version(1).On(true).OffVariation(0) + .FallthroughVariation(1).Variations(false, true) + .Prerequisites(new Prerequisite("overridden-flag", 0)) + .TrackEvents(true).DebugEventsUntilDate(debugUntil).Build(); + // The overridden flag is on and serves variation 0, so the dependent flag's prerequisite passes. + var overriddenTracked = new FeatureFlagBuilder("overridden-flag").Version(7).On(true).OffVariation(0) + .FallthroughVariation(0).Variations(LdValue.Of(true)) + .TrackEvents(true).DebugEventsUntilDate(debugUntil).Build(); + var source = new TestOverrideSource(FlagsOnly(overriddenTracked)); + using (var client = MakeInitializedClient(FlagsOnly(plainTracked, dependentTracked), source)) + { + var state = client.AllFlagsState(context, FlagsStateOption.WithReasons); + Assert.True(state.Valid); + var json = LdValue.Parse(LdJsonSerialization.SerializeObject(state)); + var flagsState = json.Get("$flagsState"); + + // A flag with no override keeps its tracking fields. + var plain = flagsState.Get("plain-tracked"); + Assert.Equal(LdValue.Of(true), plain.Get("trackEvents")); + Assert.Equal(LdValue.Of(debugUntil.Value), plain.Get("debugEventsUntilDate")); + + // The overridden flag and the flag that depends on it stay in the state with their + // values and marked reasons, but with no tracking fields. + foreach (var key in new[] { "overridden-flag", "dependent-tracked" }) + { + var entry = flagsState.Get(key); + Assert.Equal(LdValue.Of(true), entry.Get("reason").Get("overrideAffected")); + Assert.Equal(LdValue.Null, entry.Get("trackEvents")); + Assert.Equal(LdValue.Null, entry.Get("trackReason")); + Assert.Equal(LdValue.Null, entry.Get("debugEventsUntilDate")); + } + Assert.Equal(LdValue.Of(true), json.Get("overridden-flag")); + Assert.Equal(LdValue.Of(true), json.Get("dependent-tracked")); + Assert.Equal(LdValue.Of(7), flagsState.Get("overridden-flag").Get("version")); + } + } + + [Fact] + public void FlagTrackerIsNotifiedOfOverrideChanges() + { + var source = new TestOverrideSource(); + using (var client = MakeUninitializedClient(source)) + { + var events = new EventSink(); + client.FlagTracker.FlagChanged += events.Add; + + source.SetOverrides(FlagsOnly(SingleValueFlag("overridden-flag", LdValue.Of(true)))); + Assert.Equal("overridden-flag", events.ExpectValue(TimeSpan.FromSeconds(5)).Key); + + source.SetOverrides(FlagsOnly(SingleValueFlag("overridden-flag", LdValue.Of(false)))); + Assert.Equal("overridden-flag", events.ExpectValue(TimeSpan.FromSeconds(5)).Key); + + source.SetOverrides(FullDataSet.Empty()); + Assert.Equal("overridden-flag", events.ExpectValue(TimeSpan.FromSeconds(5)).Key); + } + } + + [Fact] + public void FlagValueChangeListenerSeesOverrideValueChanges() + { + var ldFlag = new FeatureFlagBuilder("flag").Version(1).OffWithValue(LdValue.Of("ld-value")).Build(); + var source = new TestOverrideSource(); + using (var client = MakeInitializedClient(FlagsOnly(ldFlag), source)) + { + var events = new EventSink(); + client.FlagTracker.FlagChanged += client.FlagTracker.FlagValueChangeHandler("flag", context, events.Add); + + source.SetOverrides(FlagsOnly(SingleValueFlag("flag", LdValue.Of("override-value")))); + var e = events.ExpectValue(TimeSpan.FromSeconds(5)); + Assert.Equal(LdValue.Of("ld-value"), e.OldValue); + Assert.Equal(LdValue.Of("override-value"), e.NewValue); + } + } + + [Fact] + public void OverrideSourceIsStartedDuringConstructionAndDisposedWithTheClient() + { + var source = new TestOverrideSource(); + var client = MakeUninitializedClient(source); + Assert.True(source.Started); + Assert.False(source.Disposed); + client.Dispose(); + Assert.True(source.Disposed); + } + + [Fact] + public void OverrideSourceIsNotStartedWhenOffline() + { + var source = new TestOverrideSource(FlagsOnly(SingleValueFlag("overridden-flag", LdValue.Of(true)))); + var config = BasicConfig().Offline(true) + .DataSystem(Components.DataSystem().Custom().Overrides(source)) + .Build(); + using (var client = new LdClient(config)) + { + Assert.False(source.Started); + Assert.False(client.BoolVariation("overridden-flag", context, false)); + } + } + + [Fact] + public void OverrideSourceConfigurationErrorPropagatesFromTheConstructor() + { + var config = BasicConfig() + .DataSystem(Components.DataSystem().Custom().Overrides(new FailingOverrideSourceConfigurer())) + .Build(); + var e = Assert.Throws(() => new LdClient(config)); + Assert.Contains("no file paths", e.Message); + } + + [Fact] + public void ClientWithoutOverridesBehavesAsBefore() + { + var config = BasicConfig() + .DataSystem(Components.DataSystem().Custom() + .Synchronizers(MockComponents.MockDataSourceThatNeverStarts())) + .Build(); + using (var client = new LdClient(config)) + { + Assert.False(client._dataSystem.OverridesConfigured); + var detail = client.BoolVariationDetail("flag", context, false); + Assert.Equal(EvaluationReason.ErrorReason(EvaluationErrorKind.ClientNotReady), detail.Reason); + Assert.False(client.AllFlagsState(context).Valid); + } + } + + [Fact] + public void InvalidContextOnUninitializedClientIsNotReadyWhenTheLayerLacksTheFlag() + { + // The layer does not hold the flag, so the evaluation takes the same path as without an + // override source: the not-ready result comes before the context is examined, and no + // event is recorded. + var events = new MockEventProcessor(); + using (var client = MakeUninitializedClient(new TestOverrideSource(), events)) + { + var detail = client.BoolVariationDetail("flag", Context.New(""), false); + Assert.Equal(EvaluationReason.ErrorReason(EvaluationErrorKind.ClientNotReady), detail.Reason); + Assert.Empty(events.Events); + } + } + + [Fact] + public void NullFlagKeyOnUninitializedClientIsNotReady() + { + // The layer holds entries of the flag kind, but no entry has a null key. The evaluation + // takes the same path as without an override source. + var source = new TestOverrideSource(FlagsOnly(SingleValueFlag("overridden-flag", LdValue.Of(true)))); + using (var client = MakeUninitializedClient(source)) + { + var detail = client.BoolVariationDetail(null, context, false); + Assert.Equal(EvaluationReason.ErrorReason(EvaluationErrorKind.ClientNotReady), detail.Reason); + } + } + + [Fact] + public void ExceptionWhileEvaluatingAnOverriddenFlagKeepsTheMarking() + { + var source = new TestOverrideSource(FlagsOnly( + SingleValueFlag(Evaluator.FlagKeyToTriggerErrorForTesting, LdValue.Of(true)))); + var events = new MockEventProcessor(); + using (var client = MakeUninitializedClient(source, events)) + { + var detail = client.BoolVariationDetail(Evaluator.FlagKeyToTriggerErrorForTesting, context, false); + + // The definition came from the override layer, so the error reason and the recorded + // event carry the override indicator. + Assert.False(detail.Value); + Assert.Equal(EvaluationReason.ErrorReason(EvaluationErrorKind.Exception).WithOverrideAffected(true), + detail.Reason); + var e = Assert.IsType(Assert.Single(events.Events)); + Assert.Equal(detail.Reason, e.Reason.Value); + } + } + + [Fact] + public void AllFlagsStateMarksAnExceptionResultOfAnOverriddenFlag() + { + var source = new TestOverrideSource(FlagsOnly( + SingleValueFlag(Evaluator.FlagKeyToTriggerErrorForTesting, LdValue.Of(true)))); + using (var client = MakeUninitializedClient(source)) + { + var state = client.AllFlagsState(context, FlagsStateOption.WithReasons); + + Assert.True(state.Valid); + var entry = LdValue.Parse(LdJsonSerialization.SerializeObject(state)) + .Get("$flagsState").Get(Evaluator.FlagKeyToTriggerErrorForTesting); + Assert.Equal(LdValue.Of("ERROR"), entry.Get("reason").Get("kind")); + Assert.Equal(LdValue.Of(true), entry.Get("reason").Get("overrideAffected")); + Assert.Equal(LdValue.Null, entry.Get("trackEvents")); + } + } + + [Fact] + public void OverrideSourceStartFailureDisposesTheClientComponentsAndPropagates() + { + var source = new TestOverrideSource { StartError = new InvalidOperationException("source failed") }; + var events = new MockEventProcessor(); + var config = BasicConfig() + .DataSystem(Components.DataSystem().Custom() + .Synchronizers(MockComponents.MockDataSourceThatNeverStarts()) + .Overrides(source)) + .Events(events.AsSingletonFactory()) + .Build(); + + var e = Assert.Throws(() => new LdClient(config)); + + Assert.Equal("source failed", e.Message); + Assert.True(source.Disposed); + Assert.True(events.Disposed); + } + + [Fact] + public void OverrideSourceBuildFailureDisposesThePersistentStore() + { + var store = new DisposalTrackingDataStore(); + var config = BasicConfig() + .DataSystem(Components.DataSystem().Custom() + .Synchronizers(MockComponents.MockDataSourceThatNeverStarts()) + .PersistentStore(store.AsSingletonFactory(), DataSystemConfiguration.DataStoreMode.ReadWrite) + .Overrides(new FailingOverrideSourceConfigurer())) + .Build(); + + Assert.Throws(() => new LdClient(config)); + + Assert.True(store.Disposed); + } + + private class DisposalTrackingDataStore : IDataStore + { + public volatile bool Disposed; + + public bool StatusMonitoringEnabled => false; + + public void Dispose() => Disposed = true; + + public ItemDescriptor? Get(DataKind kind, string key) => null; + + public KeyedItems GetAll(DataKind kind) => + new KeyedItems(Enumerable.Empty>()); + + public void Init(FullDataSet allData) { } + + public bool Initialized() => false; + + public bool Upsert(DataKind kind, string key, ItemDescriptor item) => true; + } + + private class FailingOverrideSourceConfigurer : IComponentConfigurer + { + public IOverrideSource Build(LdClientContext context) => + throw new ArgumentException("no file paths were specified"); + } + } +} diff --git a/pkgs/sdk/server/test/MockComponents.cs b/pkgs/sdk/server/test/MockComponents.cs index 6f10477b4..49f9f5c34 100644 --- a/pkgs/sdk/server/test/MockComponents.cs +++ b/pkgs/sdk/server/test/MockComponents.cs @@ -305,6 +305,60 @@ public IDataSource Build(LdClientContext context) } } + /// + /// An override source for tests. It supplies its initial data when started and forwards any later + /// snapshot to the sink. + /// + public sealed class TestOverrideSource : IOverrideSource, IComponentConfigurer + { + private readonly object _lock = new object(); + private IOverrideSink _sink; + private FullDataSet _data; + + public volatile bool Started; + public volatile bool Disposed; + public volatile bool StartedBeforeBuildReturned; + // When set, Start throws it instead of supplying data. + public volatile Exception StartError; + + public TestOverrideSource() : this(FullDataSet.Empty()) { } + + public TestOverrideSource(FullDataSet initialData) + { + _data = initialData; + } + + public void Start(IOverrideSink sink) + { + lock (_lock) + { + if (StartError != null) + { + throw StartError; + } + Started = true; + _sink = sink; + sink.SetOverrides(_data); + } + } + + public void SetOverrides(FullDataSet data) + { + lock (_lock) + { + _data = data; + _sink?.SetOverrides(data); + } + } + + public void Dispose() + { + Disposed = true; + } + + public IOverrideSource Build(LdClientContext context) => this; + } + public sealed class MockBigSegmentStore : IBigSegmentStore { private static readonly object _lock = new object(); @@ -410,6 +464,7 @@ public void FireStatusChanged(DataStoreStatus status) public class MockEventProcessor : IEventProcessor { public List Events = new List(); + public volatile bool Disposed; public void SetOffline(bool offline) { } @@ -417,7 +472,7 @@ public void Flush() { } public bool FlushAndWait(TimeSpan timeout) => true; - public void Dispose() { } + public void Dispose() => Disposed = true; public void RecordEvaluationEvent(EventProcessorTypes.EvaluationEvent e) => Events.Add(e); diff --git a/pkgs/sdk/server/test/TestFiles/override-vectors.json b/pkgs/sdk/server/test/TestFiles/override-vectors.json new file mode 100644 index 000000000..82dc62da6 --- /dev/null +++ b/pkgs/sdk/server/test/TestFiles/override-vectors.json @@ -0,0 +1,316 @@ +{ + "schemaVersion": "0.4.0", + "vectors": [ + { + "description": "An override takes precedence over LaunchDarkly data for the same flag key", + "group": "precedence", + "launchDarklyData": { + "initialized": true, + "flags": { + "flag-a": { + "key": "flag-a", + "version": 7, + "on": true, + "variations": ["ld-value"], + "fallthrough": { "variation": 0 }, + "offVariation": 0, + "salt": "salt-a" + } + }, + "segments": {} + }, + "overrides": { + "flagValues": { "flag-a": "override-value" } + }, + "evaluate": { + "flagKey": "flag-a", + "context": { "kind": "user", "key": "user-1" }, + "defaultValue": "fallback" + }, + "expect": { + "value": "override-value", + "variationIndex": 0, + "reason": { "kind": "FALLTHROUGH", "overrideAffected": true }, + "summaryOverrideAffected": true + } + }, + { + "description": "An overridden flag is served when the SDK has not initialized from LaunchDarkly", + "group": "uninitialized", + "launchDarklyData": { + "initialized": false, + "flags": {}, + "segments": {} + }, + "overrides": { + "flagValues": { "flag-a": "override-value" } + }, + "evaluate": { + "flagKey": "flag-a", + "context": { "kind": "user", "key": "user-1" }, + "defaultValue": "fallback" + }, + "expect": { + "value": "override-value", + "variationIndex": 0, + "reason": { "kind": "FALLTHROUGH", "overrideAffected": true }, + "summaryOverrideAffected": true + } + }, + { + "description": "A flag absent from the override layer is evaluated from LaunchDarkly data and is not marked", + "group": "passthrough", + "launchDarklyData": { + "initialized": true, + "flags": { + "flag-b": { + "key": "flag-b", + "version": 3, + "on": true, + "variations": ["off-value", "ld-value"], + "fallthrough": { "variation": 1 }, + "offVariation": 0, + "salt": "salt-b" + } + }, + "segments": {} + }, + "overrides": { + "flagValues": { "flag-a": "override-value" } + }, + "evaluate": { + "flagKey": "flag-b", + "context": { "kind": "user", "key": "user-1" }, + "defaultValue": "fallback" + }, + "expect": { + "value": "ld-value", + "variationIndex": 1, + "reason": { "kind": "FALLTHROUGH" }, + "summaryOverrideAffected": false + } + }, + { + "description": "A flag absent from the override layer still short-circuits when the SDK has not initialized", + "group": "passthrough", + "launchDarklyData": { + "initialized": false, + "flags": {}, + "segments": {} + }, + "overrides": { + "flagValues": { "flag-a": "override-value" } + }, + "evaluate": { + "flagKey": "flag-b", + "context": { "kind": "user", "key": "user-1" }, + "defaultValue": "fallback" + }, + "expect": { + "value": "fallback", + "variationIndex": null, + "reason": { "kind": "ERROR", "errorKind": "CLIENT_NOT_READY" } + } + }, + { + "description": "A full flag override with a matching targeting rule serves the rule's variation and is marked", + "group": "full-evaluation", + "launchDarklyData": { + "initialized": true, + "flags": {}, + "segments": {} + }, + "overrides": { + "flags": { + "flag-c": { + "key": "flag-c", + "version": 1, + "on": true, + "variations": ["default-value", "eu-value"], + "fallthrough": { "variation": 0 }, + "offVariation": 0, + "rules": [ + { + "id": "rule-eu", + "variation": 1, + "clauses": [ + { + "contextKind": "user", + "attribute": "region", + "op": "in", + "values": ["eu"], + "negate": false + } + ] + } + ], + "salt": "salt-c" + } + } + }, + "evaluate": { + "flagKey": "flag-c", + "context": { "kind": "user", "key": "user-1", "region": "eu" }, + "defaultValue": "fallback" + }, + "expect": { + "value": "eu-value", + "variationIndex": 1, + "reason": { "kind": "RULE_MATCH", "ruleIndex": 0, "ruleId": "rule-eu", "overrideAffected": true }, + "summaryOverrideAffected": true + } + }, + { + "description": "An override flag resolves a referenced segment through the merged view while the SDK is uninitialized", + "group": "merged-view", + "launchDarklyData": { + "initialized": false, + "flags": {}, + "segments": {} + }, + "overrides": { + "flags": { + "flag-d": { + "key": "flag-d", + "version": 1, + "on": true, + "variations": ["default-value", "member-value"], + "fallthrough": { "variation": 0 }, + "offVariation": 0, + "rules": [ + { + "id": "rule-seg", + "variation": 1, + "clauses": [ + { + "attribute": "", + "op": "segmentMatch", + "values": ["seg-1"], + "negate": false + } + ] + } + ], + "salt": "salt-d" + } + }, + "segments": { + "seg-1": { + "key": "seg-1", + "version": 1, + "included": ["user-1"], + "excluded": [], + "salt": "seg-salt-1" + } + } + }, + "evaluate": { + "flagKey": "flag-d", + "context": { "kind": "user", "key": "user-1" }, + "defaultValue": "fallback" + }, + "expect": { + "value": "member-value", + "variationIndex": 1, + "reason": { "kind": "RULE_MATCH", "ruleIndex": 0, "ruleId": "rule-seg", "overrideAffected": true }, + "summaryOverrideAffected": true + } + }, + { + "description": "A flag is marked overrideAffected when it matches via an overridden segment", + "group": "marking", + "launchDarklyData": { + "initialized": true, + "flags": { + "flag-f": { + "key": "flag-f", + "version": 4, + "on": true, + "variations": ["default-value", "member-value"], + "fallthrough": { "variation": 0 }, + "offVariation": 0, + "rules": [ + { + "id": "rule-seg", + "variation": 1, + "clauses": [ + { + "attribute": "", + "op": "segmentMatch", + "values": ["seg-2"], + "negate": false + } + ] + } + ], + "salt": "salt-f" + } + }, + "segments": { + "seg-2": { + "key": "seg-2", + "version": 1, + "included": [], + "excluded": [], + "salt": "seg-salt-2" + } + } + }, + "overrides": { + "segments": { + "seg-2": { + "key": "seg-2", + "version": 2, + "included": ["user-1"], + "excluded": [], + "salt": "seg-salt-2" + } + } + }, + "evaluate": { + "flagKey": "flag-f", + "context": { "kind": "user", "key": "user-1" }, + "defaultValue": "fallback" + }, + "expect": { + "value": "member-value", + "variationIndex": 1, + "reason": { "kind": "RULE_MATCH", "ruleIndex": 0, "ruleId": "rule-seg", "overrideAffected": true }, + "summaryOverrideAffected": true + } + }, + { + "description": "A malformed override flag returns the caller default with an error reason and is marked overrideAffected", + "group": "error-handling", + "launchDarklyData": { + "initialized": true, + "flags": {}, + "segments": {} + }, + "overrides": { + "flags": { + "flag-e": { + "key": "flag-e", + "version": 1, + "on": true, + "variations": ["only-value"], + "fallthrough": { "variation": 5 }, + "offVariation": 0, + "salt": "salt-e" + } + } + }, + "evaluate": { + "flagKey": "flag-e", + "context": { "kind": "user", "key": "user-1" }, + "defaultValue": "fallback" + }, + "expect": { + "value": "fallback", + "variationIndex": null, + "reason": { "kind": "ERROR", "errorKind": "MALFORMED_FLAG", "overrideAffected": true }, + "summaryOverrideAffected": true + } + } + ] +}