From fd416592f921e20c42eb43db492e66e0f6712109 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Fri, 25 Sep 2026 15:36:34 -0700 Subject: [PATCH 1/5] feat: Add the override store, overlay, source interfaces, and data system wiring Adds IOverrideSource and IOverrideSink, the DataSystemBuilder.Overrides option, and the override layer: an immutable store of marked entries, an overlay at the store read boundary that returns the override entry for a key in preference to LaunchDarkly data, and a sink that applies snapshots one at a time and sends flag change notifications for the flags an update may have changed, with dependency fan-out through prerequisites and segments. The FDv2 data system builds the configured source, starts it before the data sources so its initial load completes inside the client constructor, and disposes it with the client. The client consults the override store before its not-initialized short-circuit, so an overridden flag is served before LaunchDarkly data arrives. AllFlagsState reads through the overlay and presents override-affected flags with event tracking off. The OVERRIDE specification's test vectors run as a unit test. --- .../src/Integrations/DataSystemBuilder.cs | 39 +- .../DataSources/DataSourceUpdatesImpl.cs | 20 +- .../src/Internal/DataSystem/FDv1DataSystem.cs | 3 + .../src/Internal/DataSystem/FDv2DataSystem.cs | 48 ++- .../src/Internal/DataSystem/IDataSystem.cs | 6 + pkgs/sdk/server/src/Internal/LogNames.cs | 2 + .../src/Internal/Overrides/OverrideChanges.cs | 161 ++++++++ .../src/Internal/Overrides/OverrideLayer.cs | 111 +++++ .../src/Internal/Overrides/OverrideOverlay.cs | 99 +++++ .../src/Internal/Overrides/OverrideSink.cs | 78 ++++ pkgs/sdk/server/src/LdClient.cs | 59 ++- .../src/Subsystems/DataSystemConfiguration.cs | 14 +- .../server/src/Subsystems/IOverrideSource.cs | 87 ++++ .../DataSystemBuilderOverridesTest.cs | 36 ++ .../Internal/Overrides/FakeReadOnlyStore.cs | 74 ++++ .../Internal/Overrides/OverrideLayerTest.cs | 146 +++++++ .../Internal/Overrides/OverrideOverlayTest.cs | 131 ++++++ .../Internal/Overrides/OverrideSinkTest.cs | 150 +++++++ .../test/LaunchDarkly.ServerSdk.Tests.csproj | 3 + .../test/LdClientOverrideVectorsTest.cs | 142 +++++++ pkgs/sdk/server/test/LdClientOverridesTest.cs | 382 ++++++++++++++++++ pkgs/sdk/server/test/MockComponents.cs | 48 +++ .../test/TestFiles/override-vectors.json | 316 +++++++++++++++ 23 files changed, 2142 insertions(+), 13 deletions(-) create mode 100644 pkgs/sdk/server/src/Internal/Overrides/OverrideChanges.cs create mode 100644 pkgs/sdk/server/src/Internal/Overrides/OverrideLayer.cs create mode 100644 pkgs/sdk/server/src/Internal/Overrides/OverrideOverlay.cs create mode 100644 pkgs/sdk/server/src/Internal/Overrides/OverrideSink.cs create mode 100644 pkgs/sdk/server/src/Subsystems/IOverrideSource.cs create mode 100644 pkgs/sdk/server/test/Integrations/DataSystemBuilderOverridesTest.cs create mode 100644 pkgs/sdk/server/test/Internal/Overrides/FakeReadOnlyStore.cs create mode 100644 pkgs/sdk/server/test/Internal/Overrides/OverrideLayerTest.cs create mode 100644 pkgs/sdk/server/test/Internal/Overrides/OverrideOverlayTest.cs create mode 100644 pkgs/sdk/server/test/Internal/Overrides/OverrideSinkTest.cs create mode 100644 pkgs/sdk/server/test/LdClientOverrideVectorsTest.cs create mode 100644 pkgs/sdk/server/test/LdClientOverridesTest.cs create mode 100644 pkgs/sdk/server/test/TestFiles/override-vectors.json 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..bab5fef40 100644 --- a/pkgs/sdk/server/src/Internal/DataSystem/FDv1DataSystem.cs +++ b/pkgs/sdk/server/src/Internal/DataSystem/FDv1DataSystem.cs @@ -43,6 +43,9 @@ public Task Start() public bool Initialized => _dataSource.Initialized; + // Overrides are an option of the FDv2 data system only. + public bool OverridesConfigured => 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..43a5079e1 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,28 @@ internal class FDv2DataSystem : IDataSystem, IDisposable private readonly WriteThroughStore _store; private readonly IDataSource _dataSource; private readonly DataSourceUpdatesImpl _dataSourceUpdates; + // The override source and its sink are null when no override source is configured. + private readonly IOverrideSource _overrideSource; + 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. + _overrideSource?.Start(_overrideSink); + return _dataSource.Start(); + } public bool Initialized => _dataSource.Initialized; + public bool OverridesConfigured => _overrideSource != null; + public IFlagChanged FlagChanged { get; } public IDataSourceStatusProvider DataSourceStatusProvider { get; } public IDataStoreStatusProvider DataStoreStatusProvider { get; } @@ -34,10 +47,13 @@ internal class FDv2DataSystem : IDataSystem, IDisposable private FDv2DataSystem( WriteThroughStore store, + IReadOnlyStore readOnlyStore, IDataSource dataSource, IDataSourceStatusProvider dataSourceStatusProvider, IDataStoreStatusProvider dataStoreStatusProvider, - DataSourceUpdatesImpl dataStoreUpdates + DataSourceUpdatesImpl dataStoreUpdates, + IOverrideSource overrideSource, + OverrideSink overrideSink ) { _store = store; @@ -46,7 +62,9 @@ DataSourceUpdatesImpl dataStoreUpdates DataSourceStatusProvider = dataSourceStatusProvider; FlagChanged = new FlagChangedFacade(dataStoreUpdates); _dataSourceUpdates = dataStoreUpdates; - Store = new ReadonlyStoreFacade(store); + Store = readOnlyStore; + _overrideSource = overrideSource; + _overrideSink = overrideSink; } public static FDv2DataSystem Create(Logger logger, Configuration configuration, LdClientContext clientContext, @@ -98,8 +116,27 @@ 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; + 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. + overrideSource = dataSystemConfiguration.OverrideSource.Build(clientContext); + var 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, overrideSink); } private static Func, SourceFactory> FactoryWithContext( @@ -125,6 +162,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..62ec8f5bf 100644 --- a/pkgs/sdk/server/src/Internal/DataSystem/IDataSystem.cs +++ b/pkgs/sdk/server/src/Internal/DataSystem/IDataSystem.cs @@ -27,6 +27,12 @@ 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; } + 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..fe16c90a1 --- /dev/null +++ b/pkgs/sdk/server/src/Internal/Overrides/OverrideChanges.cs @@ -0,0 +1,161 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +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. + /// + internal static ICollection ComputeAffectedFlags( + ImmutableDictionary> oldOverrides, + ImmutableDictionary> newOverrides, + IDictionary> oldMerged, + IDictionary> newMerged + ) + { + 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); + var newTracker = TrackerFromView(newMerged); + 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) + { + var tracker = new DependencyTracker(); + foreach (var kind in DiffKinds) + { + if (view.TryGetValue(kind, out var items)) + { + foreach (var kv in items) + { + tracker.UpdateDependenciesFrom(kind, kv.Key, kv.Value); + } + } + } + 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..b05451022 --- /dev/null +++ b/pkgs/sdk/server/src/Internal/Overrides/OverrideSink.cs @@ -0,0 +1,78 @@ +using System; +using System.Collections.Generic; +using LaunchDarkly.Logging; +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); + var oldMerged = OverrideChanges.SnapshotMergedView(_base, previous); + var newMerged = OverrideChanges.SnapshotMergedView(_base, current); + + var affected = OverrideChanges.ComputeAffectedFlags(previous, current, oldMerged, newMerged); + if (affected.Count > 0) + { + _log.Debug("Override update affected {0} flag(s)", affected.Count); + _notify(affected); + } + } + } + } +} diff --git a/pkgs/sdk/server/src/LdClient.cs b/pkgs/sdk/server/src/LdClient.cs index f8b2c3e99..6029429b3 100644 --- a/pkgs/sdk/server/src/LdClient.cs +++ b/pkgs/sdk/server/src/LdClient.cs @@ -41,6 +41,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; @@ -363,6 +365,7 @@ public FeatureFlagsState AllFlagsState(Context context, params FlagsStateOption[ return new FeatureFlagsState(false); } + var overridesOnly = false; if (!Initialized) { if (_dataSystem.Store.Initialized()) @@ -372,6 +375,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 +406,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 +437,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) @@ -454,6 +488,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,6 +498,13 @@ 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.OverridesConfigured) + { + // No data from LaunchDarkly is available. The store read below still finds an + // entry that the override layer holds, and the SDK serves it. A miss returns the + // not-ready default. + noLaunchDarklyData = true; + } else { _evalLog.Warn("Flag evaluation before client initialized; data store unavailable, returning default value"); @@ -485,6 +527,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 +568,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); 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..9c57b17eb --- /dev/null +++ b/pkgs/sdk/server/test/Internal/Overrides/OverrideSinkTest.cs @@ -0,0 +1,150 @@ +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 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..62204213b --- /dev/null +++ b/pkgs/sdk/server/test/LdClientOverrideVectorsTest.cs @@ -0,0 +1,142 @@ +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 off and serves its single value. + var flag = new FeatureFlagBuilder(kv.Key).OffWithValue(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..38746b417 --- /dev/null +++ b/pkgs/sdk/server/test/LdClientOverridesTest.cs @@ -0,0 +1,382 @@ +using System; +using System.Linq; +using LaunchDarkly.Logging; +using LaunchDarkly.Sdk.Json; +using LaunchDarkly.Sdk.Server.Interfaces; +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).OffWithValue(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.Off, 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); + } + } + + 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..c63a41f7d 100644 --- a/pkgs/sdk/server/test/MockComponents.cs +++ b/pkgs/sdk/server/test/MockComponents.cs @@ -305,6 +305,54 @@ 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; + + public TestOverrideSource() : this(FullDataSet.Empty()) { } + + public TestOverrideSource(FullDataSet initialData) + { + _data = initialData; + } + + public void Start(IOverrideSink sink) + { + lock (_lock) + { + 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(); 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..48c3347c0 --- /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": "OFF", "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": "OFF", "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 + } + } + ] +} From a78f212a2cfeba837e44e92f35067f930d879d19 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Thu, 1 Oct 2026 23:18:32 +0000 Subject: [PATCH 2/5] test: Model value-only overrides as flags served by fallthrough --- pkgs/sdk/server/test/LdClientOverrideVectorsTest.cs | 5 +++-- pkgs/sdk/server/test/LdClientOverridesTest.cs | 4 ++-- pkgs/sdk/server/test/TestFiles/override-vectors.json | 4 ++-- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/pkgs/sdk/server/test/LdClientOverrideVectorsTest.cs b/pkgs/sdk/server/test/LdClientOverrideVectorsTest.cs index 62204213b..6cdf2d524 100644 --- a/pkgs/sdk/server/test/LdClientOverrideVectorsTest.cs +++ b/pkgs/sdk/server/test/LdClientOverrideVectorsTest.cs @@ -123,8 +123,9 @@ private static FullDataSet DataSetFrom(LdValue flags, LdValue se } foreach (var kv in flagValues.Dictionary) { - // A value-only entry behaves like a flag that is off and serves its single value. - var flag = new FeatureFlagBuilder(kv.Key).OffWithValue(kv.Value).Build(); + // 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>(); diff --git a/pkgs/sdk/server/test/LdClientOverridesTest.cs b/pkgs/sdk/server/test/LdClientOverridesTest.cs index 38746b417..1845f12a2 100644 --- a/pkgs/sdk/server/test/LdClientOverridesTest.cs +++ b/pkgs/sdk/server/test/LdClientOverridesTest.cs @@ -33,7 +33,7 @@ internal static FullDataSet FlagsOnly(params FeatureFlag[] flags } internal static FeatureFlag SingleValueFlag(string key, LdValue value) => - new FeatureFlagBuilder(key).OffWithValue(value).Build(); + 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) @@ -78,7 +78,7 @@ public void OverrideIsServedWhenClientIsNotInitialized() var detail = client.BoolVariationDetail("overridden-flag", context, false); Assert.True(detail.Value); Assert.Equal(0, detail.VariationIndex); - Assert.Equal(EvaluationReasonKind.Off, detail.Reason.Kind); + Assert.Equal(EvaluationReasonKind.Fallthrough, detail.Reason.Kind); Assert.True(detail.Reason.OverrideAffected); } } diff --git a/pkgs/sdk/server/test/TestFiles/override-vectors.json b/pkgs/sdk/server/test/TestFiles/override-vectors.json index 48c3347c0..82dc62da6 100644 --- a/pkgs/sdk/server/test/TestFiles/override-vectors.json +++ b/pkgs/sdk/server/test/TestFiles/override-vectors.json @@ -30,7 +30,7 @@ "expect": { "value": "override-value", "variationIndex": 0, - "reason": { "kind": "OFF", "overrideAffected": true }, + "reason": { "kind": "FALLTHROUGH", "overrideAffected": true }, "summaryOverrideAffected": true } }, @@ -53,7 +53,7 @@ "expect": { "value": "override-value", "variationIndex": 0, - "reason": { "kind": "OFF", "overrideAffected": true }, + "reason": { "kind": "FALLTHROUGH", "overrideAffected": true }, "summaryOverrideAffected": true } }, From e8fc532508d5e37fdb5b5857972e1f39afa28a35 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Sat, 3 Oct 2026 01:04:02 +0000 Subject: [PATCH 3/5] fix: Keep change notifications when an override definition is malformed A definition whose dependencies cannot be computed, such as a prerequisite without a key or a segment match on a value that is not a string, is logged and contributes no dependency edges, so every directly changed key is still notified. A failure to determine or deliver the notifications is logged instead of reaching the override source. --- .../src/Internal/Overrides/OverrideChanges.cs | 32 +++++++++++--- .../src/Internal/Overrides/OverrideSink.cs | 23 +++++++--- .../Internal/Overrides/OverrideSinkTest.cs | 42 +++++++++++++++++++ 3 files changed, 85 insertions(+), 12 deletions(-) diff --git a/pkgs/sdk/server/src/Internal/Overrides/OverrideChanges.cs b/pkgs/sdk/server/src/Internal/Overrides/OverrideChanges.cs index fe16c90a1..e0bdbcfe1 100644 --- a/pkgs/sdk/server/src/Internal/Overrides/OverrideChanges.cs +++ b/pkgs/sdk/server/src/Internal/Overrides/OverrideChanges.cs @@ -1,6 +1,8 @@ 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; @@ -20,13 +22,15 @@ internal static class OverrideChanges /// 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. + /// 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 + IDictionary> newMerged, + Logger log ) { var seeds = DiffOverrides(oldOverrides, newOverrides); @@ -39,8 +43,8 @@ IDictionary> newMerged // 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); - var newTracker = TrackerFromView(newMerged); + var oldTracker = TrackerFromView(oldMerged, log); + var newTracker = TrackerFromView(newMerged, log); var affected = new HashSet(); foreach (var seed in seeds) { @@ -142,7 +146,10 @@ ImmutableDictionary> overr return view; } - private static DependencyTracker TrackerFromView(IDictionary> view) + private static DependencyTracker TrackerFromView( + IDictionary> view, + Logger log + ) { var tracker = new DependencyTracker(); foreach (var kind in DiffKinds) @@ -151,7 +158,20 @@ private static DependencyTracker TrackerFromView(IDictionary data) } _layer.SetAll(data, out var previous, out var current); - var oldMerged = OverrideChanges.SnapshotMergedView(_base, previous); - var newMerged = OverrideChanges.SnapshotMergedView(_base, 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); - if (affected.Count > 0) + 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) { - _log.Debug("Override update affected {0} flag(s)", affected.Count); - _notify(affected); + LogHelpers.LogException(_log, "Unable to determine the flags affected by an override update", e); } } } diff --git a/pkgs/sdk/server/test/Internal/Overrides/OverrideSinkTest.cs b/pkgs/sdk/server/test/Internal/Overrides/OverrideSinkTest.cs index 9c57b17eb..4abcb5542 100644 --- a/pkgs/sdk/server/test/Internal/Overrides/OverrideSinkTest.cs +++ b/pkgs/sdk/server/test/Internal/Overrides/OverrideSinkTest.cs @@ -132,6 +132,48 @@ public void ToleratesBaseReadFailure() 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() { From fd47c4545ecfcb9686b7138d98da9c2f68f60b9c Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Sat, 3 Oct 2026 01:04:02 +0000 Subject: [PATCH 4/5] fix: Consult the override layer before the not-initialized short-circuit and dispose on a failed start Before the client is initialized, an evaluation proceeds only when the override layer holds the flag; otherwise it takes the same path as without an override source. The exception paths of evaluation and of the all-flags state keep the override indicator when the definition came from the layer. An override source whose Build or Start throws fails the client constructor after the components created so far are disposed. --- .../src/Internal/DataSystem/FDv1DataSystem.cs | 2 + .../src/Internal/DataSystem/FDv2DataSystem.cs | 43 +++++-- .../src/Internal/DataSystem/IDataSystem.cs | 6 + pkgs/sdk/server/src/LdClient.cs | 35 ++++-- pkgs/sdk/server/test/LdClientOverridesTest.cs | 110 ++++++++++++++++++ pkgs/sdk/server/test/MockComponents.cs | 9 +- 6 files changed, 190 insertions(+), 15 deletions(-) diff --git a/pkgs/sdk/server/src/Internal/DataSystem/FDv1DataSystem.cs b/pkgs/sdk/server/src/Internal/DataSystem/FDv1DataSystem.cs index bab5fef40..9f91b6212 100644 --- a/pkgs/sdk/server/src/Internal/DataSystem/FDv1DataSystem.cs +++ b/pkgs/sdk/server/src/Internal/DataSystem/FDv1DataSystem.cs @@ -46,6 +46,8 @@ public Task Start() // 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 43a5079e1..ba60079c4 100644 --- a/pkgs/sdk/server/src/Internal/DataSystem/FDv2DataSystem.cs +++ b/pkgs/sdk/server/src/Internal/DataSystem/FDv2DataSystem.cs @@ -17,8 +17,9 @@ internal class FDv2DataSystem : IDataSystem, IDisposable private readonly WriteThroughStore _store; private readonly IDataSource _dataSource; private readonly DataSourceUpdatesImpl _dataSourceUpdates; - // The override source and its sink are null when no override source is configured. + // 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; @@ -30,8 +31,18 @@ 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. - _overrideSource?.Start(_overrideSink); + // 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(); } @@ -39,6 +50,9 @@ public Task Start() public bool OverridesConfigured => _overrideSource != null; + public bool HasOverride(DataStoreTypes.DataKind kind, string key) => + _overrideLayer != null && _overrideLayer.Get(kind, key).HasValue; + public IFlagChanged FlagChanged { get; } public IDataSourceStatusProvider DataSourceStatusProvider { get; } public IDataStoreStatusProvider DataStoreStatusProvider { get; } @@ -53,6 +67,7 @@ private FDv2DataSystem( IDataStoreStatusProvider dataStoreStatusProvider, DataSourceUpdatesImpl dataStoreUpdates, IOverrideSource overrideSource, + OverrideLayer overrideLayer, OverrideSink overrideSink ) { @@ -64,6 +79,7 @@ OverrideSink overrideSink _dataSourceUpdates = dataStoreUpdates; Store = readOnlyStore; _overrideSource = overrideSource; + _overrideLayer = overrideLayer; _overrideSink = overrideSink; } @@ -122,13 +138,26 @@ public static FDv2DataSystem Create(Logger logger, Configuration configuration, // 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. - overrideSource = dataSystemConfiguration.OverrideSource.Build(clientContext); - var overrideLayer = new OverrideLayer(); + // 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)); @@ -136,7 +165,7 @@ public static FDv2DataSystem Create(Logger logger, Configuration configuration, } return new FDv2DataSystem(writeThroughStore, readOnlyStore, compositeDataSource, dataSourceStatusProvider, - dataStoreStatusProvider, dataSourceUpdates, overrideSource, overrideSink); + dataStoreStatusProvider, dataSourceUpdates, overrideSource, overrideLayer, overrideSink); } private static Func, SourceFactory> FactoryWithContext( diff --git a/pkgs/sdk/server/src/Internal/DataSystem/IDataSystem.cs b/pkgs/sdk/server/src/Internal/DataSystem/IDataSystem.cs index 62ec8f5bf..6b5da2b37 100644 --- a/pkgs/sdk/server/src/Internal/DataSystem/IDataSystem.cs +++ b/pkgs/sdk/server/src/Internal/DataSystem/IDataSystem.cs @@ -33,6 +33,12 @@ internal interface IDataSystem /// 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/LdClient.cs b/pkgs/sdk/server/src/LdClient.cs index 6029429b3..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; @@ -217,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) { @@ -466,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()); } } @@ -498,15 +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.OverridesConfigured) + else if (_dataSystem.HasOverride(DataModel.Features, featureKey)) { - // No data from LaunchDarkly is available. The store read below still finds an - // entry that the override layer holds, and the SDK serves it. A miss returns the - // not-ready default. + // 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); @@ -586,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/test/LdClientOverridesTest.cs b/pkgs/sdk/server/test/LdClientOverridesTest.cs index 1845f12a2..a9e31a913 100644 --- a/pkgs/sdk/server/test/LdClientOverridesTest.cs +++ b/pkgs/sdk/server/test/LdClientOverridesTest.cs @@ -1,8 +1,10 @@ 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; @@ -373,6 +375,114 @@ public void ClientWithoutOverridesBehavesAsBefore() } } + [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 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) => diff --git a/pkgs/sdk/server/test/MockComponents.cs b/pkgs/sdk/server/test/MockComponents.cs index c63a41f7d..49f9f5c34 100644 --- a/pkgs/sdk/server/test/MockComponents.cs +++ b/pkgs/sdk/server/test/MockComponents.cs @@ -318,6 +318,8 @@ public sealed class TestOverrideSource : IOverrideSource, IComponentConfigurer.Empty()) { } @@ -330,6 +332,10 @@ public void Start(IOverrideSink sink) { lock (_lock) { + if (StartError != null) + { + throw StartError; + } Started = true; _sink = sink; sink.SetOverrides(_data); @@ -458,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) { } @@ -465,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); From 041e7a540b27d309a6b30f423f13cea180622287 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Sat, 3 Oct 2026 01:31:53 +0000 Subject: [PATCH 5/5] fix: Treat a null flag key as not held by the override layer --- .../src/Internal/DataSystem/FDv2DataSystem.cs | 4 +++- pkgs/sdk/server/test/LdClientOverridesTest.cs | 13 +++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/pkgs/sdk/server/src/Internal/DataSystem/FDv2DataSystem.cs b/pkgs/sdk/server/src/Internal/DataSystem/FDv2DataSystem.cs index ba60079c4..bf8b0c564 100644 --- a/pkgs/sdk/server/src/Internal/DataSystem/FDv2DataSystem.cs +++ b/pkgs/sdk/server/src/Internal/DataSystem/FDv2DataSystem.cs @@ -50,8 +50,10 @@ public Task Start() 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) => - _overrideLayer != null && _overrideLayer.Get(kind, key).HasValue; + key != null && _overrideLayer != null && _overrideLayer.Get(kind, key).HasValue; public IFlagChanged FlagChanged { get; } public IDataSourceStatusProvider DataSourceStatusProvider { get; } diff --git a/pkgs/sdk/server/test/LdClientOverridesTest.cs b/pkgs/sdk/server/test/LdClientOverridesTest.cs index a9e31a913..6b2329d68 100644 --- a/pkgs/sdk/server/test/LdClientOverridesTest.cs +++ b/pkgs/sdk/server/test/LdClientOverridesTest.cs @@ -390,6 +390,19 @@ public void InvalidContextOnUninitializedClientIsNotReadyWhenTheLayerLacksTheFla } } + [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() {