diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/DataSystem.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/DataSystem.java index 6558cc45..1eb6f9bd 100644 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/DataSystem.java +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/DataSystem.java @@ -62,6 +62,15 @@ interface DataSystem { * @return the environment ID, or null */ String getEnvironmentId(); + + /** + * Returns the override layer, or null if no override source is configured. The layer holds the + * entries that take precedence over LaunchDarkly data; {@link #getStore()} already applies them. + * The client consults the layer directly only for the not-initialized short-circuit. + * + * @return the override layer, or null + */ + OverrideLayer getOverrideLayer(); } /** diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/FDv1DataSystem.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/FDv1DataSystem.java index a03da864..92020402 100644 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/FDv1DataSystem.java +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/FDv1DataSystem.java @@ -160,6 +160,11 @@ public String getEnvironmentId() { return dataStore.getEnvironmentId(); } + @Override + public OverrideLayer getOverrideLayer() { + return null; + } + @Override public void close() throws IOException { if (disposed) { diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/FDv2DataSystem.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/FDv2DataSystem.java index 5053625b..500e1ea2 100644 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/FDv2DataSystem.java +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/FDv2DataSystem.java @@ -15,6 +15,7 @@ import com.launchdarkly.sdk.server.subsystems.DataStore; import com.launchdarkly.sdk.server.subsystems.LoggingConfiguration; import com.launchdarkly.sdk.server.subsystems.DataSystemConfiguration; +import com.launchdarkly.sdk.server.subsystems.OverrideSource; import java.io.Closeable; import java.io.IOException; @@ -34,6 +35,14 @@ final class FDv2DataSystem implements DataSystem, Closeable { private final FlagChangeNotifier flagChanged; private final DataSourceStatusProvider dataSourceStatusProvider; private final DataStoreStatusProvider dataStoreStatusProvider; + private final LDLogger logger; + // The following are non-null only when an override source is configured. The layer holds the + // override entries. The overlay serves them in preference to the store's data at the store read + // boundary. The source populates the layer at runtime. None of them take part in + // initialization or data source status. + private final OverrideLayer overrideLayer; + private final OverrideSource overrideSource; + private final OverrideSinkImpl overrideSink; private boolean disposed = false; private FDv2DataSystem( @@ -41,14 +50,29 @@ private FDv2DataSystem( DataSource dataSource, DataSourceStatusProvider dataSourceStatusProvider, DataStoreStatusProvider dataStoreStatusProvider, - FlagChangeNotifier flagChanged + FlagChangeNotifier flagChanged, + EventBroadcasterImpl flagChangeBroadcaster, + OverrideSource overrideSource, + LDLogger logger ) { this.store = store; this.dataSource = dataSource; this.dataStoreStatusProvider = dataStoreStatusProvider; this.dataSourceStatusProvider = dataSourceStatusProvider; this.flagChanged = flagChanged; - this.readOnlyStore = new ReadonlyStoreFacade(store); + this.logger = logger; + ReadOnlyStore baseStore = new ReadonlyStoreFacade(store); + this.overrideSource = overrideSource; + if (overrideSource == null) { + this.overrideLayer = null; + this.overrideSink = null; + this.readOnlyStore = baseStore; + } else { + this.overrideLayer = new OverrideLayer(); + this.overrideSink = new OverrideSinkImpl(overrideLayer, baseStore, flagChangeBroadcaster, + logger.subLogger(Loggers.DATA_SOURCE_LOGGER_NAME)); + this.readOnlyStore = new OverrideOverlayStore(baseStore, overrideLayer); + } } private static class FactoryWrapper implements FDv2DataSource.DataSourceFactory { @@ -187,12 +211,23 @@ static FDv2DataSystem create( FlagChangeNotifier flagChanged = new FlagChangedFacade(dataSourceUpdates); + // The override source is built like any other component. Invalid configuration fails here, the + // same way an invalid data source or data store configuration fails. An offline client starts + // no data sources and no override source. + OverrideSource overrideSource = null; + if (dataSystemConfiguration.getOverrideSource() != null && !config.offline) { + overrideSource = dataSystemConfiguration.getOverrideSource().build(clientContext); + } + return new FDv2DataSystem( store, dataSource, dataSourceStatusProvider, dataStoreStatusProvider, - flagChanged + flagChanged, + flagChangeBroadcaster, + overrideSource, + logger ); } @@ -203,6 +238,12 @@ public ReadOnlyStore getStore() { @Override public Future start() { + if (overrideSource != null) { + // The source starts before the data source, so a source that loads synchronously has its + // overrides in place before the client begins evaluating. Its initial load is part of + // starting the client. + overrideSource.start(overrideSink); + } return dataSource.start(); } @@ -231,12 +272,24 @@ public String getEnvironmentId() { return store.getEnvironmentId(); } + @Override + public OverrideLayer getOverrideLayer() { + return overrideLayer; + } + @Override public void close() throws IOException { if (disposed) { return; } try { + if (overrideSource != null) { + try { + overrideSource.close(); + } catch (IOException | RuntimeException e) { + logger.warn("Error closing override source: {}", e.toString()); + } + } dataSource.close(); store.close(); } finally { diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/FeatureFlagsState.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/FeatureFlagsState.java index beafaaea..c4cc3476 100644 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/FeatureFlagsState.java +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/FeatureFlagsState.java @@ -276,15 +276,19 @@ public Builder add( } Builder addFlag(DataModel.FeatureFlag flag, EvalResult eval) { + // A consumer of this state sends individual events according to the tracking fields. An + // override-affected evaluation produces no individual events, so the state turns them off + // for this flag. The flag, its value, its version, and its reason stay. + boolean overrideAffected = eval.isOverrideAffected(); return add( flag.getKey(), eval.getValue(), eval.isNoVariation() ? null : eval.getVariationIndex(), eval.getReason(), flag.getVersion(), - flag.isTrackEvents() || eval.isForceReasonTracking(), - eval.isForceReasonTracking(), - flag.getDebugEventsUntilDate(), + !overrideAffected && (flag.isTrackEvents() || eval.isForceReasonTracking()), + !overrideAffected && eval.isForceReasonTracking(), + overrideAffected ? null : flag.getDebugEventsUntilDate(), eval.getPrerequisiteEvalRecords().stream() .filter(record -> record.prereqOfFlag.getKey() == flag.getKey()) // only include top level prereqs .map(record -> record.flag.getKey()) // map from prereq record to prereq key diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/InputValidatingEvaluator.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/InputValidatingEvaluator.java index 013a7b5d..bfebfb98 100644 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/InputValidatingEvaluator.java +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/InputValidatingEvaluator.java @@ -14,6 +14,7 @@ import javax.annotation.Nonnull; import javax.annotation.Nullable; import java.util.Map; +import java.util.concurrent.atomic.AtomicBoolean; import static com.launchdarkly.sdk.EvaluationDetail.NO_VARIATION; import static com.launchdarkly.sdk.server.DataModel.FEATURES; @@ -28,7 +29,11 @@ class InputValidatingEvaluator implements EvaluatorInterface { private final Evaluator evaluator; private final ReadOnlyStore store; + // Null when no override source is configured. The store already applies the layer. The layer is + // consulted directly only for the not-initialized short-circuit. + private final OverrideLayer overrideLayer; private final LDLogger logger; + private final AtomicBoolean allFlagsOverridesOnlyWarningLogged = new AtomicBoolean(false); // these are created at construction to avoid recreation during each evaluation private final EvaluationRecorder evaluationEventRecorderWithDetails; @@ -40,11 +45,14 @@ class InputValidatingEvaluator implements EvaluatorInterface { * Creates an {@link InputValidatingEvaluator} * * @param store will be used to get flag data + * @param overrideLayer the override layer, or null if no override source is configured * @param segmentStore will be used to get segment data * @param eventProcessor will be used to record events during evaluations as necessary * @param logger for logging messages and errors during evaluations */ - InputValidatingEvaluator(ReadOnlyStore store, BigSegmentStoreWrapper segmentStore, @Nonnull EventProcessor eventProcessor, LDLogger logger) { + InputValidatingEvaluator(ReadOnlyStore store, @Nullable OverrideLayer overrideLayer, BigSegmentStoreWrapper segmentStore, + @Nonnull EventProcessor eventProcessor, LDLogger logger) { + this.overrideLayer = overrideLayer; this.evaluator = new Evaluator(new Evaluator.Getters() { public DataModel.FeatureFlag getFlag(String key) { return InputValidatingEvaluator.getFlag(store, key); @@ -101,7 +109,9 @@ public EvalResultAndFlag evalAndFlag(String method, String flagKey, LDContext co */ EvalResultAndFlag evaluate(String flagKey, LDContext context, LDValue defaultValue, @Nullable LDValueType requireType, EvaluationRecorder recorder) { - if (!store.isInitialized()) { + // The override layer is consulted before the not-initialized short-circuit. A flag that the + // layer holds is served from it. Any other flag returns the not-ready default as before. + if (!store.isInitialized() && !hasOverrideFor(flagKey)) { logger.warn("Evaluation called before client initialized for feature flag \"{}\"; data store unavailable, returning default value", flagKey); recorder.recordEvaluationUnknownFlagError(flagKey, context, defaultValue, ErrorKind.CLIENT_NOT_READY); return new EvalResultAndFlag(EvalResult.error(ErrorKind.CLIENT_NOT_READY, defaultValue), null); @@ -163,8 +173,16 @@ public FeatureFlagsState allFlagsState(LDContext context, FlagsStateOption... op FeatureFlagsState.Builder builder = FeatureFlagsState.builder(options); if (!store.isInitialized()) { - logger.warn("allFlagsState() was called before client initialized; data store unavailable, returning no data"); - return builder.valid(false).build(); + // With no LaunchDarkly data, the store read below returns only the entries that the override + // layer holds. The resulting state contains only those flags. + if (overrideLayer == null || overrideLayer.isEmpty()) { + logger.warn("allFlagsState() was called before client initialized; data store unavailable, returning no data"); + return builder.valid(false).build(); + } + if (allFlagsOverridesOnlyWarningLogged.compareAndSet(false, true)) { + logger.warn("allFlagsState() was called before client initialized; returning only flags from the override layer." + + " This message is logged once."); + } } if (context == null) { @@ -214,6 +232,14 @@ private static DataModel.FeatureFlag getFlag(ReadOnlyStore store, String key) { return item == null ? null : (DataModel.FeatureFlag) item.getItem(); } + private boolean hasOverrideFor(String flagKey) { + if (overrideLayer == null) { + return false; + } + DataStoreTypes.ItemDescriptor item = overrideLayer.get(FEATURES, flagKey); + return item != null && item.getItem() != null; + } + private static DataModel.Segment getSegment(ReadOnlyStore store, String key) { DataStoreTypes.ItemDescriptor item = store.get(SEGMENTS, key); return item == null ? null : (DataModel.Segment) item.getItem(); diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/LDClient.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/LDClient.java index 18f314ca..88186d76 100644 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/LDClient.java +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/LDClient.java @@ -209,7 +209,8 @@ public LDClient(String sdkKey, LDConfig config) { this.dataSystem = FDv1DataSystem.create(baseLogger, config, context, context.getLogging()); } - EvaluatorInterface evaluator = new InputValidatingEvaluator(this.dataSystem.getStore(), bigSegmentStoreWrapper, eventProcessor, evaluationLogger); + EvaluatorInterface evaluator = new InputValidatingEvaluator(this.dataSystem.getStore(), + this.dataSystem.getOverrideLayer(), bigSegmentStoreWrapper, eventProcessor, evaluationLogger); // build environment metadata for plugins SdkMetadata sdkMetadata; diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/OverrideLayer.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/OverrideLayer.java new file mode 100644 index 00000000..02db4edd --- /dev/null +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/OverrideLayer.java @@ -0,0 +1,133 @@ +package com.launchdarkly.sdk.server; + +import com.google.common.collect.ImmutableMap; +import com.launchdarkly.sdk.server.DataModel.FeatureFlag; +import com.launchdarkly.sdk.server.DataModel.Segment; +import com.launchdarkly.sdk.server.subsystems.DataStoreTypes.DataKind; +import com.launchdarkly.sdk.server.subsystems.DataStoreTypes.ItemDescriptor; +import com.launchdarkly.sdk.server.subsystems.DataStoreTypes.KeyedItems; + +import java.util.HashMap; +import java.util.Map; + +/** + * The override layer: a thread-safe store of override entries that an override source replaces + * wholesale on each update. Entries take precedence over LaunchDarkly data at the store read + * boundary; see {@link OverrideOverlayStore}. + *

+ * Each entry is a marked shallow copy of the entity that the source supplied. The copy shares its + * nested collections with the source's entity, and the layer never writes to them. The source's + * entity is never marked, so a source may retain the entities it supplied and supply them again. + *

+ * This class is package-private and should not be used by application code. + */ +final class OverrideLayer { + /** + * The previous and current contents of the layer after a replacement. The maps must not be + * modified. + */ + static final class Replacement { + final ImmutableMap> previous; + final ImmutableMap> current; + + Replacement( + ImmutableMap> previous, + ImmutableMap> current + ) { + this.previous = previous; + this.current = current; + } + } + + // The contents are an immutable map that is swapped on each update, so the layer holds exactly + // one snapshot at any instant and readers never take a lock. + private volatile ImmutableMap> contents = ImmutableMap.of(); + // A single volatile read decides the common case of a configured but unpopulated layer. + private volatile boolean nonEmpty = false; + private final Object writeLock = new Object(); + + /** + * Atomically replaces the entire layer contents. A null or empty collection clears the layer. + * + * @param data the complete set of entries, grouped by data kind + * @return the previous and the new contents + */ + Replacement setAll(Iterable>> data) { + Map> collected = new HashMap<>(); + int count = 0; + if (data != null) { + for (Map.Entry> kindEntry : data) { + Map items = collected.computeIfAbsent(kindEntry.getKey(), k -> new HashMap<>()); + Iterable> kindItems = kindEntry.getValue() == null + ? null : kindEntry.getValue().getItems(); + if (kindItems == null) { + continue; + } + for (Map.Entry item : kindItems) { + items.put(item.getKey(), markedCopy(item.getValue())); + count++; + } + } + } + ImmutableMap.Builder> builder = ImmutableMap.builder(); + for (Map.Entry> e : collected.entrySet()) { + builder.put(e.getKey(), ImmutableMap.copyOf(e.getValue())); + } + ImmutableMap> replacement = builder.build(); + + synchronized (writeLock) { + ImmutableMap> previous = contents; + contents = replacement; + nonEmpty = count != 0; + return new Replacement(previous, replacement); + } + } + + /** + * Returns the override entry for a key, or null if the layer has none. + * + * @param kind the data kind + * @param key the item key + * @return the marked entry or null + */ + ItemDescriptor get(DataKind kind, String key) { + if (!nonEmpty) { + return null; + } + ImmutableMap items = contents.get(kind); + return items == null ? null : items.get(key); + } + + /** + * Returns the entries of a kind. The map must not be modified. + * + * @param kind the data kind + * @return the entries, possibly empty + */ + ImmutableMap all(DataKind kind) { + ImmutableMap items = contents.get(kind); + return items == null ? ImmutableMap.of() : items; + } + + /** + * Reports whether the layer contains no entries. + * + * @return true if empty + */ + boolean isEmpty() { + return !nonEmpty; + } + + // Returns a copy of the descriptor whose item carries the override marker. An item of another + // type, or a deleted item placeholder, is returned as is. + static ItemDescriptor markedCopy(ItemDescriptor item) { + Object entity = item.getItem(); + if (entity instanceof FeatureFlag) { + return new ItemDescriptor(item.getVersion(), ((FeatureFlag) entity).markedAsOverride()); + } + if (entity instanceof Segment) { + return new ItemDescriptor(item.getVersion(), ((Segment) entity).markedAsOverride()); + } + return item; + } +} diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/OverrideOverlayStore.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/OverrideOverlayStore.java new file mode 100644 index 00000000..f80f45bf --- /dev/null +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/OverrideOverlayStore.java @@ -0,0 +1,94 @@ +package com.launchdarkly.sdk.server; + +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import com.launchdarkly.sdk.server.subsystems.DataStoreTypes.DataKind; +import com.launchdarkly.sdk.server.subsystems.DataStoreTypes.ItemDescriptor; +import com.launchdarkly.sdk.server.subsystems.DataStoreTypes.KeyedItems; + +import java.util.AbstractMap; +import java.util.Collections; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; + +/** + * Merges an {@link OverrideLayer} over a base store. A read for a key returns the override entry + * when one exists, and the base entry otherwise. The overlay sits at the store read boundary, so + * targeting rules, prerequisites, and segment matches behave identically for overridden and + * ordinary data: they are the same reads through the same boundary. + *

+ * This class is package-private and should not be used by application code. + */ +final class OverrideOverlayStore implements ReadOnlyStore { + private final ReadOnlyStore base; + private final OverrideLayer layer; + + OverrideOverlayStore(ReadOnlyStore base, OverrideLayer layer) { + this.base = base; + this.layer = layer; + } + + /** + * Returns the override entry for the key if one exists, and otherwise delegates to the base + * store. This works even when the base store is uninitialized, because an uninitialized base + * reports not found rather than failing. + */ + @Override + public ItemDescriptor get(DataKind kind, String key) { + ItemDescriptor override = layer.get(kind, key); + if (override != null) { + return override; + } + 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 keys that the base holds as deleted-item placeholders. + *

+ * 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. + */ + @Override + public KeyedItems getAll(DataKind kind) { + ImmutableMap overrides = layer.all(kind); + Iterable> baseItems; + try { + KeyedItems fromBase = base.getAll(kind); + if (overrides.isEmpty()) { + return fromBase; + } + baseItems = fromBase == null || fromBase.getItems() == null ? Collections.emptyList() : fromBase.getItems(); + } catch (RuntimeException e) { + if (overrides.isEmpty()) { + throw e; + } + baseItems = Collections.emptyList(); + } + + ImmutableList.Builder> result = ImmutableList.builder(); + Set seen = new HashSet<>(); + for (Map.Entry item : baseItems) { + ItemDescriptor override = overrides.get(item.getKey()); + result.add(override == null ? item : new AbstractMap.SimpleEntry<>(item.getKey(), override)); + seen.add(item.getKey()); + } + for (Map.Entry override : overrides.entrySet()) { + if (!seen.contains(override.getKey())) { + result.add(override); + } + } + return new KeyedItems<>(result.build()); + } + + /** + * Delegates to the base store. The override layer never affects initialization status or data + * availability. + */ + @Override + public boolean isInitialized() { + return base.isInitialized(); + } +} diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/OverrideSinkImpl.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/OverrideSinkImpl.java new file mode 100644 index 00000000..a18f9f95 --- /dev/null +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/OverrideSinkImpl.java @@ -0,0 +1,194 @@ +package com.launchdarkly.sdk.server; + +import com.google.common.collect.ImmutableMap; +import com.launchdarkly.logging.LDLogger; +import com.launchdarkly.sdk.server.DataModelDependencies.DependencyTracker; +import com.launchdarkly.sdk.server.DataModelDependencies.KindAndKey; +import com.launchdarkly.sdk.server.interfaces.FlagChangeEvent; +import com.launchdarkly.sdk.server.interfaces.FlagChangeListener; +import com.launchdarkly.sdk.server.subsystems.DataStoreTypes.DataKind; +import com.launchdarkly.sdk.server.subsystems.DataStoreTypes.ItemDescriptor; +import com.launchdarkly.sdk.server.subsystems.DataStoreTypes.KeyedItems; +import com.launchdarkly.sdk.server.subsystems.OverrideSink; + +import java.util.HashMap; +import java.util.LinkedHashSet; +import java.util.Map; +import java.util.Objects; +import java.util.Set; + +import static com.launchdarkly.sdk.server.DataModel.FEATURES; +import static com.launchdarkly.sdk.server.DataModel.SEGMENTS; + +/** + * Applies override layer replacements supplied by an override source, and notifies flag change + * listeners of the flags affected by each replacement. + *

+ * This class is package-private and should not be used by application code. + */ +final class OverrideSinkImpl implements OverrideSink { + private static final DataKind[] DIFF_KINDS = new DataKind[] { FEATURES, SEGMENTS }; + + private final OverrideLayer layer; + private final ReadOnlyStore base; + private final EventBroadcasterImpl flagChangeBroadcaster; + private final LDLogger logger; + + /** + * Creates a sink that writes to the given layer. + * + * @param layer the override layer + * @param base the raw store that holds LaunchDarkly data, without the overlay. Merged-view + * snapshots for change computation are built from it plus the layer. + * @param flagChangeBroadcaster the client's flag change broadcaster + * @param logger the logger + */ + OverrideSinkImpl( + OverrideLayer layer, + ReadOnlyStore base, + EventBroadcasterImpl flagChangeBroadcaster, + LDLogger logger + ) { + this.layer = layer; + this.base = base; + this.flagChangeBroadcaster = flagChangeBroadcaster; + this.logger = logger; + } + + /** + * 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. + */ + @Override + public synchronized void setOverrides(Iterable>> data) { + // Computing affected flags requires snapshots of the merged view before and after the + // replacement. Skip all of that work when nothing is listening. + if (!flagChangeBroadcaster.hasListeners()) { + layer.setAll(data); + return; + } + + OverrideLayer.Replacement replacement = layer.setAll(data); + Map> oldMerged = snapshotMergedView(replacement.previous); + Map> newMerged = snapshotMergedView(replacement.current); + + Set affected = computeAffectedFlags(replacement.previous, replacement.current, oldMerged, newMerged); + if (!affected.isEmpty()) { + logger.debug("Override update affected {} flag(s)", affected.size()); + } + for (String key : affected) { + flagChangeBroadcaster.broadcast(new FlagChangeEvent(key)); + } + } + + // Returns the keys of all flags whose merged-view evaluation may have changed when the 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. + static Set computeAffectedFlags( + ImmutableMap> oldOverrides, + ImmutableMap> newOverrides, + Map> oldMerged, + Map> newMerged + ) { + Set seeds = diffOverrides(oldOverrides, newOverrides); + Set flagKeys = new LinkedHashSet<>(); + if (seeds.isEmpty()) { + return flagKeys; + } + + // 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. + DependencyTracker oldTracker = trackerFromView(oldMerged); + DependencyTracker newTracker = trackerFromView(newMerged); + Set affected = new LinkedHashSet<>(); + for (KindAndKey seed : seeds) { + oldTracker.addAffectedItems(affected, seed); + newTracker.addAffectedItems(affected, seed); + } + for (KindAndKey item : affected) { + if (item.kind == FEATURES) { + flagKeys.add(item.key); + } + } + return flagKeys; + } + + // Returns 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: the override marker alone changes the served entry. Entries present in both snapshots + // are compared by version and serialized form, because the layer is rebuilt wholesale on every + // update and identity comparison would report every retained entry as changed. + private static Set diffOverrides( + ImmutableMap> oldOverrides, + ImmutableMap> newOverrides + ) { + Set seeds = new LinkedHashSet<>(); + for (DataKind kind : DIFF_KINDS) { + Map oldItems = oldOverrides.getOrDefault(kind, ImmutableMap.of()); + Map newItems = newOverrides.getOrDefault(kind, ImmutableMap.of()); + for (Map.Entry e : oldItems.entrySet()) { + ItemDescriptor newItem = newItems.get(e.getKey()); + if (newItem == null || !itemsEqual(kind, e.getValue(), newItem)) { + seeds.add(new KindAndKey(kind, e.getKey())); + } + } + for (String key : newItems.keySet()) { + if (!oldItems.containsKey(key)) { + seeds.add(new KindAndKey(kind, key)); + } + } + } + return seeds; + } + + private static boolean itemsEqual(DataKind kind, ItemDescriptor a, ItemDescriptor b) { + if (a.getVersion() != b.getVersion()) { + return false; + } + return Objects.equals(kind.serialize(a), kind.serialize(b)); + } + + // Captures the data visible at the store read boundary: base data with the given 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. + private Map> snapshotMergedView( + ImmutableMap> overrides + ) { + Map> view = new HashMap<>(); + for (DataKind kind : DIFF_KINDS) { + Map items = new HashMap<>(); + try { + KeyedItems baseItems = base.getAll(kind); + if (baseItems != null && baseItems.getItems() != null) { + for (Map.Entry item : baseItems.getItems()) { + items.put(item.getKey(), item.getValue()); + } + } + } catch (RuntimeException e) { + logger.debug("Unable to read {} from the data store while computing override changes: {}", + kind.getName(), e.toString()); + } + items.putAll(overrides.getOrDefault(kind, ImmutableMap.of())); + view.put(kind, items); + } + return view; + } + + private static DependencyTracker trackerFromView(Map> view) { + DependencyTracker tracker = new DependencyTracker(); + for (DataKind kind : DIFF_KINDS) { + Map items = view.get(kind); + if (items == null) { + continue; + } + for (Map.Entry e : items.entrySet()) { + tracker.updateDependenciesFrom(kind, e.getKey(), e.getValue()); + } + } + return tracker; + } +} diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/DataSystemBuilder.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/DataSystemBuilder.java index 73d1f3b3..b07cc037 100644 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/DataSystemBuilder.java +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/DataSystemBuilder.java @@ -19,6 +19,7 @@ public final class DataSystemBuilder { private ComponentConfigurer fDv1FallbackSynchronizer; private ComponentConfigurer persistentStore; private DataSystemConfiguration.DataStoreMode persistentDataStoreMode; + private ComponentConfigurer overrideSource; /** * Add one or more initializers to the builder. @@ -108,13 +109,45 @@ public DataSystemBuilder persistentStore(ComponentConfigurer persiste 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 supplies an + * override. At most one override source can be configured; a later call replaces the earlier one. + *

+ *

+ * Example: + *

+ *

+   *     LDConfig config = new LDConfig.Builder()
+   *       .dataSystem(Components.dataSystem().defaultMode()
+   *         .overrides(FileOverrides.source().filePaths("/etc/launchdarkly/overrides.json")))
+   *       .build();
+   * 
+ * + * @param overrideSource the override source configuration, or null for none + * @return a reference to the builder + * @since 7.18.0 + */ + public DataSystemBuilder overrides(ComponentConfigurer overrideSource) { + this.overrideSource = overrideSource; + return this; + } + /** * Build the data system configuration. *

* This method is internal and should not be called by application code. * This function should remain internal. *

- * + * * @return the data system configuration */ public DataSystemConfiguration build() { @@ -123,7 +156,8 @@ public DataSystemConfiguration build() { ImmutableList.copyOf(synchronizers), fDv1FallbackSynchronizer, persistentStore, - persistentDataStoreMode); + persistentDataStoreMode, + overrideSource); } } diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/subsystems/DataSystemConfiguration.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/subsystems/DataSystemConfiguration.java index 34788d3c..692d7998 100644 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/subsystems/DataSystemConfiguration.java +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/subsystems/DataSystemConfiguration.java @@ -38,13 +38,14 @@ public enum DataStoreMode { private final ComponentConfigurer fDv1FallbackSynchronizer; private final ComponentConfigurer persistentStore; private final DataStoreMode persistentDataStoreMode; + private final ComponentConfigurer overrideSource; /** - * Creates an instance. + * Creates an instance without an override source. *

* This constructor is internal and should not be called by application code. *

- * + * * @param initializers see {@link #getInitializers()} * @param synchronizers see {@link #getSynchronizers()} * @param fDv1FallbackSynchronizer see {@link #getFDv1FallbackSynchronizer()} @@ -57,11 +58,36 @@ public DataSystemConfiguration( ComponentConfigurer fDv1FallbackSynchronizer, ComponentConfigurer persistentStore, DataStoreMode persistentDataStoreMode) { + this(initializers, synchronizers, fDv1FallbackSynchronizer, persistentStore, persistentDataStoreMode, null); + } + + /** + * Creates an instance. + *

+ * This constructor is internal and should not be called by application code. + *

+ * + * @param initializers see {@link #getInitializers()} + * @param synchronizers see {@link #getSynchronizers()} + * @param fDv1FallbackSynchronizer see {@link #getFDv1FallbackSynchronizer()} + * @param persistentStore see {@link #getPersistentStore()} + * @param persistentDataStoreMode see {@link #getPersistentDataStoreMode()} + * @param overrideSource see {@link #getOverrideSource()} + * @since 7.18.0 + */ + public DataSystemConfiguration( + ImmutableList> initializers, + ImmutableList> synchronizers, + ComponentConfigurer fDv1FallbackSynchronizer, + ComponentConfigurer persistentStore, + DataStoreMode persistentDataStoreMode, + ComponentConfigurer overrideSource) { this.initializers = initializers; this.synchronizers = synchronizers; this.fDv1FallbackSynchronizer = fDv1FallbackSynchronizer; this.persistentStore = persistentStore; this.persistentDataStoreMode = persistentDataStoreMode; + this.overrideSource = overrideSource; } /** @@ -107,11 +133,24 @@ public ComponentConfigurer getPersistentStore() { /** * The mode of operation for the persistent data store. - * + * * @return the persistent data store mode */ public DataStoreMode getPersistentDataStoreMode() { return persistentDataStoreMode; } + + /** + * An optional factory for creating an override source. The source supplies flag and segment + * overrides that take precedence over LaunchDarkly data at evaluation time. It is not part of + * the initializer or synchronizer pipeline and does not affect the client's initialization + * status. Flag overrides are currently experimental and subject to change. + * + * @return the override source configurer, or null + * @since 7.18.0 + */ + public ComponentConfigurer getOverrideSource() { + return overrideSource; + } } diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/subsystems/OverrideSink.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/subsystems/OverrideSink.java new file mode 100644 index 00000000..b8fab1e5 --- /dev/null +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/subsystems/OverrideSink.java @@ -0,0 +1,36 @@ +package com.launchdarkly.sdk.server.subsystems; + +import com.launchdarkly.sdk.server.subsystems.DataStoreTypes.DataKind; +import com.launchdarkly.sdk.server.subsystems.DataStoreTypes.ItemDescriptor; +import com.launchdarkly.sdk.server.subsystems.DataStoreTypes.KeyedItems; + +import java.util.Map; + +/** + * Receives the contents of the SDK's flag and segment override layer. The SDK implements this + * interface and passes it to {@link OverrideSource#start(OverrideSink)}. Override sources call + * it. They do not implement it. + *

+ * Flag overrides are currently experimental and subject to change. + * + * @since 7.18.0 + */ +public interface OverrideSink { + /** + * Replaces the entire override layer with the given flag and segment entries. An empty + * collection clears the layer. Each call is a full snapshot: entries absent from the call are + * removed from the layer. + *

+ * The data uses the SDK's standard data kinds, {@link com.launchdarkly.sdk.server.DataModel#FEATURES} + * and {@link com.launchdarkly.sdk.server.DataModel#SEGMENTS}, with item descriptors whose items + * are the SDK's flag and segment model objects, as produced by + * {@link DataKind#deserialize(String)}. Sources supply ordinary, fully parsed entities. The SDK + * itself marks the entries as overrides. The source's objects are never modified. + *

+ * This method is safe to call from any thread. The SDK serializes calls, and the new layer + * contents are visible to evaluations when the call returns. + * + * @param data the complete set of override entries, grouped by data kind + */ + void setOverrides(Iterable>> data); +} diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/subsystems/OverrideSource.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/subsystems/OverrideSource.java new file mode 100644 index 00000000..0e83f01e --- /dev/null +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/subsystems/OverrideSource.java @@ -0,0 +1,36 @@ +package com.launchdarkly.sdk.server.subsystems; + +import java.io.Closeable; + +/** + * Supplies flag and segment overrides that take precedence over LaunchDarkly data 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. + *

+ * An override source is not a data source. It does not take part in the data system's initializer + * and synchronizer pipeline. The override layer that it populates has no effect on the client's + * initialization status, data availability, or data source status. + *

+ * To configure an override source, use + * {@link com.launchdarkly.sdk.server.integrations.DataSystemBuilder#overrides(ComponentConfigurer)}. + *

+ * Flag overrides are currently experimental and subject to change. + * + * @since 7.18.0 + */ +public interface OverrideSource extends Closeable { + /** + * Begins supplying overrides to the sink and returns without blocking on long-running work. + *

+ * Implementations typically perform an initial load synchronously, then push a full replacement + * snapshot to the sink whenever their backing data changes, until {@link #close()} is called. A + * failed load should leave the previously supplied layer untouched by not calling the sink. + *

+ * The SDK calls this method at most once, before any call to {@link #close()}, while the client + * is being constructed. + * + * @param sink receives override snapshots + */ + void start(OverrideSink sink); +} diff --git a/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/InputValidatingEvaluatorOverrideTest.java b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/InputValidatingEvaluatorOverrideTest.java index cf92d61c..ab605d4c 100644 --- a/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/InputValidatingEvaluatorOverrideTest.java +++ b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/InputValidatingEvaluatorOverrideTest.java @@ -50,7 +50,7 @@ public boolean isInitialized() { } private static InputValidatingEvaluator evaluatorOver(ReadOnlyStore store) { - return new InputValidatingEvaluator(store, null, new NoOpEventProcessor(), nullLogger); + return new InputValidatingEvaluator(store, null, null, new NoOpEventProcessor(), nullLogger); } @Test diff --git a/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/LDClientOverridesTest.java b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/LDClientOverridesTest.java new file mode 100644 index 00000000..9e039c18 --- /dev/null +++ b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/LDClientOverridesTest.java @@ -0,0 +1,383 @@ +package com.launchdarkly.sdk.server; + +import com.launchdarkly.logging.LDLogLevel; +import com.launchdarkly.sdk.EvaluationDetail; +import com.launchdarkly.sdk.EvaluationReason; +import com.launchdarkly.sdk.EvaluationReason.ErrorKind; +import com.launchdarkly.sdk.LDContext; +import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.json.JsonSerialization; +import com.launchdarkly.sdk.server.DataModel.FeatureFlag; +import com.launchdarkly.sdk.server.DataModel.Segment; +import com.launchdarkly.sdk.server.integrations.DataSystemBuilder; +import com.launchdarkly.sdk.server.interfaces.FlagChangeEvent; +import com.launchdarkly.sdk.server.subsystems.ComponentConfigurer; +import com.launchdarkly.sdk.server.subsystems.DataStoreTypes.DataKind; +import com.launchdarkly.sdk.server.subsystems.DataStoreTypes.ItemDescriptor; +import com.launchdarkly.sdk.server.subsystems.DataStoreTypes.KeyedItems; +import com.launchdarkly.sdk.server.subsystems.OverrideSource; + +import org.junit.Test; + +import java.time.Duration; +import java.util.Collections; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.TimeUnit; + +import static com.launchdarkly.sdk.server.ModelBuilders.clauseMatchingSegment; +import static com.launchdarkly.sdk.server.ModelBuilders.flagBuilder; +import static com.launchdarkly.sdk.server.ModelBuilders.prerequisite; +import static com.launchdarkly.sdk.server.ModelBuilders.ruleBuilder; +import static com.launchdarkly.sdk.server.ModelBuilders.segmentBuilder; +import static com.launchdarkly.sdk.server.OverrideLayerTest.collections; +import static com.launchdarkly.sdk.server.OverrideLayerTest.flagsOnly; +import static com.launchdarkly.sdk.server.OverrideTestDataSources.hangingSynchronizer; +import static com.launchdarkly.sdk.server.OverrideTestDataSources.initializerWith; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.hasItem; +import static org.hamcrest.Matchers.not; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +/** + * The override layer through the client: the not-initialized short-circuit, the all-flags state, + * flag change notifications, and the override source lifecycle. + */ +@SuppressWarnings("javadoc") +public class LDClientOverridesTest extends BaseTest { + private static final String SDK_KEY = "sdk-key"; + private static final LDContext CONTEXT = LDContext.create("user-key"); + private static final LDValue DEFAULT = LDValue.of("default"); + + private static FeatureFlag valueFlag(String key, String value) { + return flagBuilder(key).version(1).on(false).offVariation(0).variations(LDValue.of(value)).build(); + } + + private LDConfig.Builder configWith(DataSystemBuilder dataSystem) { + return new LDConfig.Builder() + .dataSystem(dataSystem) + .events(Components.noEvents()) + .logging(Components.logging(testLogging).level(LDLogLevel.DEBUG)); + } + + private LDConfig initializedConfig(Iterable>> ldData, + ComponentConfigurer source) { + return configWith(Components.dataSystem().custom().initializers(initializerWith(ldData)).overrides(source)).build(); + } + + private LDConfig uninitializedConfig(ComponentConfigurer source) { + return configWith(Components.dataSystem().custom().synchronizers(hangingSynchronizer()).overrides(source)) + .startWait(Duration.ZERO) + .build(); + } + + private static void assertOverrideServed(EvaluationDetail detail, String value) { + assertEquals(LDValue.of(value), detail.getValue()); + assertEquals(0, detail.getVariationIndex()); + assertEquals(EvaluationReason.off().withOverrideAffected(true), detail.getReason()); + } + + private static void assertNotReady(EvaluationDetail detail) { + assertEquals(DEFAULT, detail.getValue()); + assertTrue(detail.isDefaultValue()); + assertEquals(EvaluationReason.error(ErrorKind.CLIENT_NOT_READY), detail.getReason()); + } + + @Test + public void overrideIsServedWhenClientIsNotInitialized() throws Exception { + TestOverrideSource source = new TestOverrideSource(flagsOnly(valueFlag("overridden", "override-value"))); + try (LDClient client = new LDClient(SDK_KEY, uninitializedConfig(source))) { + assertFalse(client.isInitialized()); + assertOverrideServed(client.jsonValueVariationDetail("overridden", CONTEXT, DEFAULT), "override-value"); + assertEquals("override-value", client.stringVariation("overridden", CONTEXT, "default")); + } + } + + @Test + public void nonOverriddenFlagStillShortCircuitsWhenClientIsNotInitialized() throws Exception { + TestOverrideSource source = new TestOverrideSource(flagsOnly(valueFlag("overridden", "override-value"))); + try (LDClient client = new LDClient(SDK_KEY, uninitializedConfig(source))) { + assertNotReady(client.jsonValueVariationDetail("other", CONTEXT, DEFAULT)); + assertThat(logCapture.getMessageStrings(), hasItem( + "WARN:Evaluation called before client initialized for feature flag \"other\"; data store unavailable, returning default value")); + } + } + + @Test + public void overrideRemovalRestoresShortCircuit() throws Exception { + TestOverrideSource source = new TestOverrideSource(flagsOnly(valueFlag("overridden", "override-value"))); + try (LDClient client = new LDClient(SDK_KEY, uninitializedConfig(source))) { + assertOverrideServed(client.jsonValueVariationDetail("overridden", CONTEXT, DEFAULT), "override-value"); + source.setOverrides(Collections.emptyList()); + assertNotReady(client.jsonValueVariationDetail("overridden", CONTEXT, DEFAULT)); + } + } + + @Test + public void overrideTakesPrecedenceOverLaunchDarklyDataAndOtherFlagsAreUnaffected() throws Exception { + Iterable>> ldData = flagsOnly( + valueFlag("shared", "ld-value"), valueFlag("plain", "plain-value")); + TestOverrideSource source = new TestOverrideSource(flagsOnly(valueFlag("shared", "override-value"))); + try (LDClient client = new LDClient(SDK_KEY, initializedConfig(ldData, source))) { + assertTrue(client.isInitialized()); + assertOverrideServed(client.jsonValueVariationDetail("shared", CONTEXT, DEFAULT), "override-value"); + EvaluationDetail plain = client.jsonValueVariationDetail("plain", CONTEXT, DEFAULT); + assertEquals(LDValue.of("plain-value"), plain.getValue()); + assertEquals(EvaluationReason.off(), plain.getReason()); + assertFalse(plain.getReason().isOverrideAffected()); + + // Removing the override returns the flag to LaunchDarkly data. + source.setOverrides(Collections.emptyList()); + EvaluationDetail restored = client.jsonValueVariationDetail("shared", CONTEXT, DEFAULT); + assertEquals(LDValue.of("ld-value"), restored.getValue()); + assertFalse(restored.getReason().isOverrideAffected()); + } + } + + @Test + public void clientWithoutOverrideSourceBehavesAsBefore() throws Exception { + Iterable>> ldData = flagsOnly(valueFlag("plain", "plain-value")); + LDConfig config = configWith(Components.dataSystem().custom().initializers(initializerWith(ldData))).build(); + try (LDClient client = new LDClient(SDK_KEY, config)) { + assertNull(client.dataSystem.getOverrideLayer()); + assertEquals(LDValue.of("plain-value"), client.jsonValueVariation("plain", CONTEXT, DEFAULT)); + } + } + + @Test + public void allFlagsStateContainsOnlyOverridesWhenClientIsNotInitialized() throws Exception { + TestOverrideSource source = new TestOverrideSource(flagsOnly(valueFlag("overridden", "override-value"))); + try (LDClient client = new LDClient(SDK_KEY, uninitializedConfig(source))) { + FeatureFlagsState state = client.allFlagsState(CONTEXT, FlagsStateOption.WITH_REASONS); + assertTrue(state.isValid()); + assertEquals(Collections.singletonMap("overridden", LDValue.of("override-value")), state.toValuesMap()); + assertTrue(state.getFlagReason("overridden").isOverrideAffected()); + } + } + + @Test + public void allFlagsStateOverridesOnlyWarningIsLoggedOnce() throws Exception { + TestOverrideSource source = new TestOverrideSource(flagsOnly(valueFlag("overridden", "override-value"))); + try (LDClient client = new LDClient(SDK_KEY, uninitializedConfig(source))) { + client.allFlagsState(CONTEXT); + client.allFlagsState(CONTEXT); + String message = "WARN:allFlagsState() was called before client initialized; returning only flags from the override layer." + + " This message is logged once."; + assertEquals(1, logCapture.getMessageStrings().stream().filter(m -> m.equals(message)).count()); + } + } + + @Test + public void allFlagsStateIsInvalidWhenNotInitializedAndOverrideLayerIsEmpty() throws Exception { + TestOverrideSource source = new TestOverrideSource(Collections.emptyList()); + try (LDClient client = new LDClient(SDK_KEY, uninitializedConfig(source))) { + FeatureFlagsState state = client.allFlagsState(CONTEXT); + assertFalse(state.isValid()); + assertThat(logCapture.getMessageStrings(), hasItem( + "WARN:allFlagsState() was called before client initialized; data store unavailable, returning no data")); + } + } + + @Test + public void allFlagsStateTurnsOffEventTrackingForOverrideAffectedFlags() throws Exception { + long debugUntil = System.currentTimeMillis() + 100000; + FeatureFlag ldTracked = flagBuilder("tracked-overridden").version(100).on(false).offVariation(0) + .variations(LDValue.of("ld-value")).trackEvents(true).debugEventsUntilDate(debugUntil).build(); + FeatureFlag ldPlain = flagBuilder("plain").version(100).on(false).offVariation(0) + .variations(LDValue.of("plain-value")).trackEvents(true).debugEventsUntilDate(debugUntil).build(); + FeatureFlag overrideOnly = flagBuilder("override-only").version(1).on(true).fallthroughVariation(0).offVariation(0) + .variations(LDValue.of("override-only-value")).trackEvents(true).trackEventsFallthrough(true) + .debugEventsUntilDate(debugUntil).build(); + TestOverrideSource source = new TestOverrideSource(flagsOnly( + valueFlag("tracked-overridden", "override-value"), overrideOnly)); + + try (LDClient client = new LDClient(SDK_KEY, initializedConfig(flagsOnly(ldTracked, ldPlain), source))) { + FeatureFlagsState state = client.allFlagsState(CONTEXT, FlagsStateOption.WITH_REASONS); + LDValue json = LDValue.parse(JsonSerialization.serialize(state)); + LDValue flagsState = json.get("$flagsState"); + + // An override-affected flag keeps its value, version, and marked reason, but every tracking + // field is off. + assertEquals(LDValue.of("override-value"), json.get("tracked-overridden")); + LDValue overridden = flagsState.get("tracked-overridden"); + assertEquals(LDValue.of(1), overridden.get("version")); + assertTrue(overridden.get("reason").get("overrideAffected").booleanValue()); + assertTrue(overridden.get("trackEvents").isNull()); + assertTrue(overridden.get("trackReason").isNull()); + assertTrue(overridden.get("debugEventsUntilDate").isNull()); + + // The same holds for a flag that exists only in the override layer, whatever its definition + // requests, including reason tracking from an experiment-like fallthrough. + LDValue only = flagsState.get("override-only"); + assertTrue(only.get("trackEvents").isNull()); + assertTrue(only.get("trackReason").isNull()); + assertTrue(only.get("debugEventsUntilDate").isNull()); + + // A flag with no override keeps its tracking. + LDValue plain = flagsState.get("plain"); + assertTrue(plain.get("trackEvents").booleanValue()); + assertEquals(LDValue.of(debugUntil), plain.get("debugEventsUntilDate")); + assertFalse(plain.get("reason").get("overrideAffected").booleanValue()); + } + } + + @Test + public void flagTrackerIsNotifiedOfOverrideChanges() throws Exception { + // "dependent" depends on "target" in LaunchDarkly data. Overriding "target" affects both. + FeatureFlag target = valueFlag("target", "ld-value"); + FeatureFlag dependent = flagBuilder("dependent").version(1).on(true).fallthroughVariation(0).offVariation(0) + .variations(LDValue.of("x")).prerequisites(prerequisite("target", 0)).build(); + FeatureFlag unrelated = valueFlag("unrelated", "u"); + TestOverrideSource source = new TestOverrideSource(Collections.emptyList()); + + try (LDClient client = new LDClient(SDK_KEY, initializedConfig(flagsOnly(target, dependent, unrelated), source))) { + BlockingQueue events = new LinkedBlockingQueue<>(); + client.getFlagTracker().addFlagChangeListener(events::add); + + source.setOverrides(flagsOnly(valueFlag("target", "override-value"))); + assertEquals(keys("target", "dependent"), awaitKeys(events, 2)); + assertNull(events.poll(200, TimeUnit.MILLISECONDS)); + + source.setOverrides(flagsOnly(valueFlag("target", "override-value-2"))); + assertEquals(keys("target", "dependent"), awaitKeys(events, 2)); + + source.setOverrides(Collections.emptyList()); + assertEquals(keys("target", "dependent"), awaitKeys(events, 2)); + assertNull(events.poll(200, TimeUnit.MILLISECONDS)); + } + } + + @Test + public void flagValueChangeListenerSeesOverrideChanges() throws Exception { + TestOverrideSource source = new TestOverrideSource(Collections.emptyList()); + try (LDClient client = new LDClient(SDK_KEY, initializedConfig(flagsOnly(valueFlag("flag", "ld-value")), source))) { + BlockingQueue values = new LinkedBlockingQueue<>(); + client.getFlagTracker().addFlagValueChangeListener("flag", CONTEXT, e -> values.add(e.getNewValue())); + + source.setOverrides(flagsOnly(valueFlag("flag", "override-value"))); + assertEquals(LDValue.of("override-value"), values.poll(5, TimeUnit.SECONDS)); + + source.setOverrides(Collections.emptyList()); + assertEquals(LDValue.of("ld-value"), values.poll(5, TimeUnit.SECONDS)); + } + } + + @Test + public void segmentOverrideNotifiesFlagsThatReferenceIt() throws Exception { + FeatureFlag usesSegment = flagBuilder("uses-segment").version(1).on(true).fallthroughVariation(0).offVariation(0) + .variations(LDValue.of("no"), LDValue.of("yes")) + .rules(ruleBuilder().id("r").variation(1).clauses(clauseMatchingSegment("seg")).build()).build(); + Segment ldSegment = segmentBuilder("seg").version(1).build(); + TestOverrideSource source = new TestOverrideSource(Collections.emptyList()); + + try (LDClient client = new LDClient(SDK_KEY, initializedConfig( + collections(Collections.singletonList(usesSegment), Collections.singletonList(ldSegment)), source))) { + assertEquals(LDValue.of("no"), client.jsonValueVariation("uses-segment", CONTEXT, DEFAULT)); + BlockingQueue events = new LinkedBlockingQueue<>(); + client.getFlagTracker().addFlagChangeListener(events::add); + + Segment overrideSegment = segmentBuilder("seg").version(2).included(CONTEXT.getKey()).build(); + source.setOverrides(collections(Collections.emptyList(), Collections.singletonList(overrideSegment))); + + assertEquals(keys("uses-segment"), awaitKeys(events, 1)); + EvaluationDetail detail = client.jsonValueVariationDetail("uses-segment", CONTEXT, DEFAULT); + assertEquals(LDValue.of("yes"), detail.getValue()); + assertTrue(detail.getReason().isOverrideAffected()); + } + } + + @Test + public void overrideSourceIsStartedBeforeConstructorReturnsAndClosedWithClient() throws Exception { + TestOverrideSource source = new TestOverrideSource(flagsOnly(valueFlag("flag", "override-value"))); + LDClient client = new LDClient(SDK_KEY, initializedConfig(flagsOnly(valueFlag("flag", "ld-value")), source)); + try { + assertTrue(source.started.get()); + assertFalse(source.closed.get()); + assertEquals(LDValue.of("override-value"), client.jsonValueVariation("flag", CONTEXT, DEFAULT)); + } finally { + client.close(); + } + assertTrue(source.closed.get()); + } + + @Test + public void overrideSourceIsBuiltWithClientContext() throws Exception { + TestOverrideSource source = new TestOverrideSource(Collections.emptyList()); + try (LDClient client = new LDClient(SDK_KEY, initializedConfig(flagsOnly(), source))) { + assertEquals(SDK_KEY, source.buildContext.getSdkKey()); + } + } + + @Test + public void offlineClientDoesNotStartOverrideSource() throws Exception { + TestOverrideSource source = new TestOverrideSource(flagsOnly(valueFlag("flag", "override-value"))); + LDConfig config = configWith(Components.dataSystem().custom().overrides(source)).offline(true).build(); + try (LDClient client = new LDClient(SDK_KEY, config)) { + assertFalse(source.started.get()); + assertNull(client.dataSystem.getOverrideLayer()); + assertEquals(DEFAULT, client.jsonValueVariation("flag", CONTEXT, DEFAULT)); + } + assertFalse(source.closed.get()); + } + + @Test + public void invalidOverrideSourceConfigurationFailsClientConstruction() { + ComponentConfigurer broken = context -> { + throw new IllegalArgumentException("no file paths were specified"); + }; + LDConfig config = configWith(Components.dataSystem().custom().initializers(initializerWith(flagsOnly())).overrides(broken)) + .build(); + try { + new LDClient(SDK_KEY, config).close(); + fail("expected exception"); + } catch (IllegalArgumentException e) { + assertEquals("no file paths were specified", e.getMessage()); + } catch (Exception e) { + fail("unexpected exception " + e); + } + } + + @Test + public void isFlagKnownSeesOverrideOnlyFlagWhenInitialized() throws Exception { + TestOverrideSource source = new TestOverrideSource(flagsOnly(valueFlag("override-only", "v"))); + try (LDClient client = new LDClient(SDK_KEY, initializedConfig(flagsOnly(valueFlag("plain", "p")), source))) { + assertTrue(client.isFlagKnown("override-only")); + assertTrue(client.isFlagKnown("plain")); + assertFalse(client.isFlagKnown("missing")); + } + } + + @Test + public void overrideLayerDoesNotAffectInitializationStatus() throws Exception { + TestOverrideSource source = new TestOverrideSource(flagsOnly(valueFlag("flag", "override-value"))); + try (LDClient client = new LDClient(SDK_KEY, uninitializedConfig(source))) { + assertFalse(client.isInitialized()); + assertThat(client.getDataSourceStatusProvider().getStatus().getState().toString(), not("VALID")); + } + } + + private static Set keys(String... keys) { + Set s = new HashSet<>(); + Collections.addAll(s, keys); + return s; + } + + private static Set awaitKeys(BlockingQueue events, int count) throws InterruptedException { + Set keys = new HashSet<>(); + for (int i = 0; i < count; i++) { + FlagChangeEvent e = events.poll(5, TimeUnit.SECONDS); + if (e == null) { + throw new AssertionError("expected " + count + " flag change events but got " + keys); + } + keys.add(e.getKey()); + } + return keys; + } +} diff --git a/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/OverrideLayerTest.java b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/OverrideLayerTest.java new file mode 100644 index 00000000..a1acc3ca --- /dev/null +++ b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/OverrideLayerTest.java @@ -0,0 +1,135 @@ +package com.launchdarkly.sdk.server; + +import com.launchdarkly.sdk.server.DataModel.FeatureFlag; +import com.launchdarkly.sdk.server.DataModel.Segment; +import com.launchdarkly.sdk.server.subsystems.DataStoreTypes.DataKind; +import com.launchdarkly.sdk.server.subsystems.DataStoreTypes.ItemDescriptor; +import com.launchdarkly.sdk.server.subsystems.DataStoreTypes.KeyedItems; + +import org.junit.Test; + +import java.util.AbstractMap; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +import static com.launchdarkly.sdk.server.DataModel.FEATURES; +import static com.launchdarkly.sdk.server.DataModel.SEGMENTS; +import static com.launchdarkly.sdk.server.ModelBuilders.flagBuilder; +import static com.launchdarkly.sdk.server.ModelBuilders.segmentBuilder; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNotSame; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; + +@SuppressWarnings("javadoc") +public class OverrideLayerTest { + static Iterable>> collections( + Iterable flags, Iterable segments) { + List> flagItems = new ArrayList<>(); + for (FeatureFlag f : flags) { + flagItems.add(new AbstractMap.SimpleEntry<>(f.getKey(), new ItemDescriptor(f.getVersion(), f))); + } + List> segmentItems = new ArrayList<>(); + for (Segment s : segments) { + segmentItems.add(new AbstractMap.SimpleEntry<>(s.getKey(), new ItemDescriptor(s.getVersion(), s))); + } + List>> result = new ArrayList<>(); + result.add(new AbstractMap.SimpleEntry<>(FEATURES, new KeyedItems<>(flagItems))); + result.add(new AbstractMap.SimpleEntry<>(SEGMENTS, new KeyedItems<>(segmentItems))); + return result; + } + + static Iterable>> flagsOnly(FeatureFlag... flags) { + return collections(java.util.Arrays.asList(flags), Collections.emptyList()); + } + + @Test + public void newLayerIsEmpty() { + OverrideLayer layer = new OverrideLayer(); + assertTrue(layer.isEmpty()); + assertNull(layer.get(FEATURES, "flag")); + assertTrue(layer.all(FEATURES).isEmpty()); + } + + @Test + public void setAllStoresMarkedCopiesWithoutMutatingSource() { + OverrideLayer layer = new OverrideLayer(); + FeatureFlag flag = flagBuilder("flag1").version(2).build(); + Segment segment = segmentBuilder("segment1").version(3).build(); + + layer.setAll(collections(Collections.singletonList(flag), Collections.singletonList(segment))); + + assertFalse(flag.isOverride()); + assertFalse(segment.isOverride()); + assertFalse(layer.isEmpty()); + + ItemDescriptor storedFlag = layer.get(FEATURES, "flag1"); + assertNotNull(storedFlag); + assertEquals(2, storedFlag.getVersion()); + assertTrue(((FeatureFlag) storedFlag.getItem()).isOverride()); + assertNotSame(flag, storedFlag.getItem()); + assertSame(flag.preprocessed, ((FeatureFlag) storedFlag.getItem()).preprocessed); + + ItemDescriptor storedSegment = layer.get(SEGMENTS, "segment1"); + assertNotNull(storedSegment); + assertEquals(3, storedSegment.getVersion()); + assertTrue(((Segment) storedSegment.getItem()).isOverride()); + } + + @Test + public void setAllReplacesEverything() { + OverrideLayer layer = new OverrideLayer(); + layer.setAll(flagsOnly(flagBuilder("a").build(), flagBuilder("b").build())); + assertNotNull(layer.get(FEATURES, "a")); + assertNotNull(layer.get(FEATURES, "b")); + + OverrideLayer.Replacement replacement = layer.setAll(flagsOnly(flagBuilder("b").build(), flagBuilder("c").build())); + assertNull(layer.get(FEATURES, "a")); + assertNotNull(layer.get(FEATURES, "b")); + assertNotNull(layer.get(FEATURES, "c")); + assertEquals(2, replacement.previous.get(FEATURES).size()); + assertEquals(2, replacement.current.get(FEATURES).size()); + assertTrue(replacement.previous.get(FEATURES).containsKey("a")); + assertTrue(replacement.current.get(FEATURES).containsKey("c")); + } + + @Test + public void emptyOrNullSnapshotClearsLayer() { + OverrideLayer layer = new OverrideLayer(); + layer.setAll(flagsOnly(flagBuilder("a").build())); + assertFalse(layer.isEmpty()); + + layer.setAll(Collections.emptyList()); + assertTrue(layer.isEmpty()); + assertNull(layer.get(FEATURES, "a")); + + layer.setAll(flagsOnly(flagBuilder("a").build())); + layer.setAll(null); + assertTrue(layer.isEmpty()); + } + + @Test + public void deletedPlaceholderIsStoredAsIs() { + OverrideLayer layer = new OverrideLayer(); + ItemDescriptor deleted = ItemDescriptor.deletedItem(5); + layer.setAll(Collections.singletonList(new AbstractMap.SimpleEntry<>(FEATURES, + new KeyedItems<>(Collections.singletonList(new AbstractMap.SimpleEntry<>("gone", deleted)))))); + assertSame(deleted, layer.get(FEATURES, "gone")); + } + + @Test + public void allReturnsEntriesOfKindOnly() { + OverrideLayer layer = new OverrideLayer(); + layer.setAll(collections(Collections.singletonList(flagBuilder("f").build()), + Collections.singletonList(segmentBuilder("s").build()))); + assertEquals(1, layer.all(FEATURES).size()); + assertEquals(1, layer.all(SEGMENTS).size()); + assertTrue(layer.all(FEATURES).containsKey("f")); + assertTrue(layer.all(SEGMENTS).containsKey("s")); + } +} diff --git a/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/OverrideOverlayStoreTest.java b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/OverrideOverlayStoreTest.java new file mode 100644 index 00000000..4206f49c --- /dev/null +++ b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/OverrideOverlayStoreTest.java @@ -0,0 +1,180 @@ +package com.launchdarkly.sdk.server; + +import com.launchdarkly.sdk.server.DataModel.FeatureFlag; +import com.launchdarkly.sdk.server.subsystems.DataStoreTypes.DataKind; +import com.launchdarkly.sdk.server.subsystems.DataStoreTypes.ItemDescriptor; +import com.launchdarkly.sdk.server.subsystems.DataStoreTypes.KeyedItems; + +import org.junit.Test; + +import java.util.HashMap; +import java.util.Map; + +import static com.launchdarkly.sdk.server.DataModel.FEATURES; +import static com.launchdarkly.sdk.server.DataModel.SEGMENTS; +import static com.launchdarkly.sdk.server.DataStoreTestTypes.toItemsMap; +import static com.launchdarkly.sdk.server.ModelBuilders.flagBuilder; +import static com.launchdarkly.sdk.server.OverrideLayerTest.flagsOnly; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +@SuppressWarnings("javadoc") +public class OverrideOverlayStoreTest { + /** + * A base store with arbitrary data, initialization state, and an optional read failure. + */ + static final class FakeBaseStore implements ReadOnlyStore { + final Map> data = new HashMap<>(); + boolean initialized = true; + RuntimeException getAllError = null; + int getAllCalls = 0; + + FakeBaseStore withFlag(FeatureFlag flag) { + data.computeIfAbsent(FEATURES, k -> new HashMap<>()).put(flag.getKey(), new ItemDescriptor(flag.getVersion(), flag)); + return this; + } + + FakeBaseStore withDeletedFlag(String key, int version) { + data.computeIfAbsent(FEATURES, k -> new HashMap<>()).put(key, ItemDescriptor.deletedItem(version)); + return this; + } + + @Override + public ItemDescriptor get(DataKind kind, String key) { + Map items = data.get(kind); + return items == null ? null : items.get(key); + } + + @Override + public KeyedItems getAll(DataKind kind) { + getAllCalls++; + if (getAllError != null) { + throw getAllError; + } + Map items = data.get(kind); + return new KeyedItems<>(items == null ? null : items.entrySet()); + } + + @Override + public boolean isInitialized() { + return initialized; + } + } + + private static FeatureFlag flagOf(ItemDescriptor item) { + return (FeatureFlag) item.getItem(); + } + + @Test + public void getPrefersOverrideEntry() { + FeatureFlag ldFlag = flagBuilder("shared").version(1).on(true).build(); + FeatureFlag ldOnly = flagBuilder("ld-only").version(1).build(); + FeatureFlag overrideFlag = flagBuilder("shared").version(2).on(false).build(); + FeatureFlag overrideOnly = flagBuilder("override-only").version(1).build(); + FakeBaseStore base = new FakeBaseStore().withFlag(ldFlag).withFlag(ldOnly); + OverrideLayer layer = new OverrideLayer(); + layer.setAll(flagsOnly(overrideFlag, overrideOnly)); + OverrideOverlayStore overlay = new OverrideOverlayStore(base, layer); + + ItemDescriptor shared = overlay.get(FEATURES, "shared"); + assertEquals(2, shared.getVersion()); + assertTrue(flagOf(shared).isOverride()); + assertFalse(flagOf(shared).isOn()); + + ItemDescriptor onlyLd = overlay.get(FEATURES, "ld-only"); + assertFalse(flagOf(onlyLd).isOverride()); + assertSame(ldOnly, onlyLd.getItem()); + + assertTrue(flagOf(overlay.get(FEATURES, "override-only")).isOverride()); + assertNull(overlay.get(FEATURES, "missing")); + assertNull(overlay.get(SEGMENTS, "shared")); + } + + @Test + public void getServesOverridesFromUninitializedBase() { + FakeBaseStore base = new FakeBaseStore(); + base.initialized = false; + OverrideLayer layer = new OverrideLayer(); + layer.setAll(flagsOnly(flagBuilder("flag").build())); + OverrideOverlayStore overlay = new OverrideOverlayStore(base, layer); + + assertFalse(overlay.isInitialized()); + assertTrue(flagOf(overlay.get(FEATURES, "flag")).isOverride()); + assertNull(overlay.get(FEATURES, "other")); + } + + @Test + public void isInitializedDelegatesToBase() { + FakeBaseStore base = new FakeBaseStore(); + OverrideLayer layer = new OverrideLayer(); + OverrideOverlayStore overlay = new OverrideOverlayStore(base, layer); + assertTrue(overlay.isInitialized()); + base.initialized = false; + assertFalse(overlay.isInitialized()); + layer.setAll(flagsOnly(flagBuilder("flag").build())); + assertFalse(overlay.isInitialized()); + } + + @Test + public void getAllIsUnionWithOverridePrecedence() { + FakeBaseStore base = new FakeBaseStore() + .withFlag(flagBuilder("shared").version(1).build()) + .withFlag(flagBuilder("ld-only").version(1).build()) + .withDeletedFlag("tombstoned", 9); + OverrideLayer layer = new OverrideLayer(); + layer.setAll(flagsOnly( + flagBuilder("shared").version(2).build(), + flagBuilder("override-only").version(1).build(), + flagBuilder("tombstoned").version(1).build())); + OverrideOverlayStore overlay = new OverrideOverlayStore(base, layer); + + Map all = toItemsMap(overlay.getAll(FEATURES)); + assertEquals(4, all.size()); + assertEquals(2, all.get("shared").getVersion()); + assertTrue(flagOf(all.get("shared")).isOverride()); + assertFalse(flagOf(all.get("ld-only")).isOverride()); + assertTrue(flagOf(all.get("override-only")).isOverride()); + // The override wins over a deleted-item placeholder in the base. + assertTrue(flagOf(all.get("tombstoned")).isOverride()); + } + + @Test + public void getAllWithEmptyLayerReturnsBaseResultAsIs() { + FakeBaseStore base = new FakeBaseStore().withFlag(flagBuilder("a").build()); + OverrideOverlayStore overlay = new OverrideOverlayStore(base, new OverrideLayer()); + KeyedItems all = overlay.getAll(FEATURES); + assertEquals(1, toItemsMap(all).size()); + assertFalse(flagOf(toItemsMap(all).get("a")).isOverride()); + } + + @Test + public void getAllServesOverridesWhenBaseFails() { + FakeBaseStore base = new FakeBaseStore(); + base.getAllError = new RuntimeException("store down"); + OverrideLayer layer = new OverrideLayer(); + layer.setAll(flagsOnly(flagBuilder("flag").build())); + OverrideOverlayStore overlay = new OverrideOverlayStore(base, layer); + + Map all = toItemsMap(overlay.getAll(FEATURES)); + assertEquals(1, all.size()); + assertTrue(flagOf(all.get("flag")).isOverride()); + } + + @Test + public void getAllPropagatesBaseFailureWhenLayerIsEmpty() { + FakeBaseStore base = new FakeBaseStore(); + RuntimeException error = new RuntimeException("store down"); + base.getAllError = error; + OverrideOverlayStore overlay = new OverrideOverlayStore(base, new OverrideLayer()); + try { + overlay.getAll(FEATURES); + fail("expected exception"); + } catch (RuntimeException e) { + assertSame(error, e); + } + } +} diff --git a/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/OverrideSinkImplTest.java b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/OverrideSinkImplTest.java new file mode 100644 index 00000000..45e5061e --- /dev/null +++ b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/OverrideSinkImplTest.java @@ -0,0 +1,194 @@ +package com.launchdarkly.sdk.server; + +import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.server.DataModel.FeatureFlag; +import com.launchdarkly.sdk.server.DataModel.Segment; +import com.launchdarkly.sdk.server.OverrideOverlayStoreTest.FakeBaseStore; +import com.launchdarkly.sdk.server.interfaces.FlagChangeEvent; +import com.launchdarkly.sdk.server.interfaces.FlagChangeListener; + +import org.junit.Test; + +import java.util.Collections; +import java.util.HashSet; +import java.util.Set; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.TimeUnit; + +import static com.launchdarkly.sdk.server.ModelBuilders.clauseMatchingSegment; +import static com.launchdarkly.sdk.server.ModelBuilders.flagBuilder; +import static com.launchdarkly.sdk.server.ModelBuilders.prerequisite; +import static com.launchdarkly.sdk.server.ModelBuilders.ruleBuilder; +import static com.launchdarkly.sdk.server.ModelBuilders.segmentBuilder; +import static com.launchdarkly.sdk.server.OverrideLayerTest.collections; +import static com.launchdarkly.sdk.server.OverrideLayerTest.flagsOnly; +import static com.launchdarkly.sdk.server.TestComponents.nullLogger; +import static com.launchdarkly.sdk.server.TestComponents.sharedExecutor; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +@SuppressWarnings("javadoc") +public class OverrideSinkImplTest { + private final FakeBaseStore base = new FakeBaseStore(); + private final OverrideLayer layer = new OverrideLayer(); + private final EventBroadcasterImpl broadcaster = + EventBroadcasterImpl.forFlagChangeEvents(sharedExecutor, nullLogger); + private final OverrideSinkImpl sink = new OverrideSinkImpl(layer, base, broadcaster, nullLogger); + private final BlockingQueue events = new LinkedBlockingQueue<>(); + + private void listen() { + broadcaster.register(events::add); + } + + private Set awaitKeys(int count) throws InterruptedException { + Set keys = new HashSet<>(); + for (int i = 0; i < count; i++) { + FlagChangeEvent e = events.poll(5, TimeUnit.SECONDS); + if (e == null) { + throw new AssertionError("expected " + count + " flag change events but got " + keys); + } + keys.add(e.getKey()); + } + return keys; + } + + private void assertNoMoreEvents() throws InterruptedException { + assertNull(events.poll(200, TimeUnit.MILLISECONDS)); + } + + private static Set keys(String... keys) { + Set s = new HashSet<>(); + Collections.addAll(s, keys); + return s; + } + + @Test + public void setOverridesReplacesLayer() { + sink.setOverrides(flagsOnly(flagBuilder("a").build())); + assertTrue(((FeatureFlag) layer.get(DataModel.FEATURES, "a").getItem()).isOverride()); + sink.setOverrides(Collections.emptyList()); + assertTrue(layer.isEmpty()); + } + + @Test + public void notifiesOnAddChangeAndRemove() throws Exception { + listen(); + + sink.setOverrides(flagsOnly(flagBuilder("a").version(1).on(true).build())); + assertEquals(keys("a"), awaitKeys(1)); + + // The same content again is not a change. + sink.setOverrides(flagsOnly(flagBuilder("a").version(1).on(true).build())); + assertNoMoreEvents(); + + // A content change at the same version is a change. + sink.setOverrides(flagsOnly(flagBuilder("a").version(1).on(false).build())); + assertEquals(keys("a"), awaitKeys(1)); + + // A version change is a change. + sink.setOverrides(flagsOnly(flagBuilder("a").version(2).on(false).build())); + assertEquals(keys("a"), awaitKeys(1)); + + // Removal is a change. + sink.setOverrides(Collections.emptyList()); + assertEquals(keys("a"), awaitKeys(1)); + assertNoMoreEvents(); + } + + @Test + public void addedOverrideIdenticalToLaunchDarklyDataIsStillAChange() throws Exception { + FeatureFlag ldFlag = flagBuilder("a").version(1).on(true).build(); + base.withFlag(ldFlag); + listen(); + + sink.setOverrides(flagsOnly(flagBuilder("a").version(1).on(true).build())); + assertEquals(keys("a"), awaitKeys(1)); + } + + @Test + public void segmentOverrideFansOutToDependentFlags() throws Exception { + FeatureFlag usesSegment = flagBuilder("uses-segment").on(true) + .rules(ruleBuilder().id("r").variation(0).clauses(clauseMatchingSegment("seg")).build()).build(); + FeatureFlag unrelated = flagBuilder("unrelated").build(); + base.withFlag(usesSegment).withFlag(unrelated); + listen(); + + Segment segment = segmentBuilder("seg").included("u").build(); + sink.setOverrides(collections(Collections.emptyList(), Collections.singletonList(segment))); + + assertEquals(keys("uses-segment"), awaitKeys(1)); + assertNoMoreEvents(); + } + + @Test + public void prerequisiteFanOutUsesOldAndNewViews() throws Exception { + // The LaunchDarkly definition of "parent" depends on "prereq". The override of "parent" removes + // that dependency. Adding the override affects "parent". Later changing the override of + // "prereq" does not affect "parent" while the parent override is in place. Removing the parent + // override restores the edge, and "parent" is affected again. + FeatureFlag ldParent = flagBuilder("parent").on(true).prerequisites(prerequisite("prereq", 0)).build(); + FeatureFlag ldPrereq = flagBuilder("prereq").on(true).build(); + base.withFlag(ldParent).withFlag(ldPrereq); + listen(); + + FeatureFlag parentOverride = flagBuilder("parent").version(5).on(true).build(); + sink.setOverrides(flagsOnly(parentOverride)); + assertEquals(keys("parent"), awaitKeys(1)); + + sink.setOverrides(flagsOnly(parentOverride, flagBuilder("prereq").version(7).on(false).build())); + assertEquals(keys("prereq"), awaitKeys(1)); + assertNoMoreEvents(); + + // Removing both overrides: "prereq" changes, and "parent" depends on it in the new view. + sink.setOverrides(Collections.emptyList()); + assertEquals(keys("parent", "prereq"), awaitKeys(2)); + assertNoMoreEvents(); + } + + @Test + public void removingOverrideThatReferencedSegmentAffectsOnlyThatFlag() throws Exception { + // The override of "flag" depends on segment "seg" that exists only in the base. Removing the + // override affects "flag" through the old view. Nothing else is affected. + base.withFlag(flagBuilder("flag").build()); + listen(); + FeatureFlag override = flagBuilder("flag").on(true) + .rules(ruleBuilder().id("r").variation(0).clauses(clauseMatchingSegment("seg")).build()).build(); + sink.setOverrides(flagsOnly(override)); + assertEquals(keys("flag"), awaitKeys(1)); + + sink.setOverrides(Collections.emptyList()); + assertEquals(keys("flag"), awaitKeys(1)); + assertNoMoreEvents(); + } + + @Test + public void skipsDiffWorkWithoutListeners() { + base.withFlag(flagBuilder("a").build()); + sink.setOverrides(flagsOnly(flagBuilder("a").version(2).build())); + assertEquals(0, base.getAllCalls); + assertEquals(2, layer.get(DataModel.FEATURES, "a").getVersion()); + } + + @Test + public void toleratesBaseReadFailure() throws Exception { + base.getAllError = new RuntimeException("store down"); + listen(); + + sink.setOverrides(flagsOnly(flagBuilder("a").version(1).build())); + + // The directly changed key is still reported and the layer is updated. + assertEquals(keys("a"), awaitKeys(1)); + assertEquals(1, layer.get(DataModel.FEATURES, "a").getVersion()); + } + + @Test + public void valueChangeInSameVersionIsDetectedThroughSerializedForm() throws Exception { + listen(); + sink.setOverrides(flagsOnly(flagBuilder("a").version(1).variations(LDValue.of("x")).build())); + awaitKeys(1); + sink.setOverrides(flagsOnly(flagBuilder("a").version(1).variations(LDValue.of("y")).build())); + assertEquals(keys("a"), awaitKeys(1)); + } +} diff --git a/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/OverrideSpecVectorsTest.java b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/OverrideSpecVectorsTest.java new file mode 100644 index 00000000..467a362e --- /dev/null +++ b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/OverrideSpecVectorsTest.java @@ -0,0 +1,152 @@ +package com.launchdarkly.sdk.server; + +import com.launchdarkly.logging.LDLogLevel; +import com.launchdarkly.sdk.EvaluationDetail; +import com.launchdarkly.sdk.LDContext; +import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.json.JsonSerialization; +import com.launchdarkly.sdk.server.integrations.DataSystemBuilder; +import com.launchdarkly.sdk.server.subsystems.DataStoreTypes.DataKind; +import com.launchdarkly.sdk.server.subsystems.DataStoreTypes.ItemDescriptor; +import com.launchdarkly.sdk.server.subsystems.DataStoreTypes.KeyedItems; + +import org.junit.Test; + +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.AbstractMap; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Scanner; + +import static com.launchdarkly.sdk.server.DataModel.FEATURES; +import static com.launchdarkly.sdk.server.DataModel.SEGMENTS; +import static com.launchdarkly.sdk.server.OverrideTestDataSources.hangingSynchronizer; +import static com.launchdarkly.sdk.server.OverrideTestDataSources.initializerWith; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +/** + * 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. + */ +@SuppressWarnings("javadoc") +public class OverrideSpecVectorsTest extends BaseTest { + private static final String VECTORS_RESOURCE = "/override-vectors/vectors.json"; + // The vectors' semantics are versioned. A schema change means this runner needs review. + private static final String SUPPORTED_SCHEMA_VERSION = "0.4.0"; + + static LDValue loadVectors() { + try (InputStream in = OverrideSpecVectorsTest.class.getResourceAsStream(VECTORS_RESOURCE)) { + if (in == null) { + throw new AssertionError("vectors resource not found: " + VECTORS_RESOURCE); + } + Scanner scanner = new Scanner(in, StandardCharsets.UTF_8.name()).useDelimiter("\\A"); + return LDValue.parse(scanner.hasNext() ? scanner.next() : ""); + } catch (java.io.IOException e) { + throw new AssertionError(e); + } + } + + static Iterable>> collectionsFromJson( + LDValue flags, LDValue flagValues, LDValue segments) { + List> flagItems = new ArrayList<>(); + for (String key : flags.keys()) { + flagItems.add(new AbstractMap.SimpleEntry<>(key, FEATURES.deserialize(flags.get(key).toJsonString()))); + } + for (String key : flagValues.keys()) { + // A value-only entry is a flag that is off and serves the value as its single variation. + LDValue flag = LDValue.buildObject().put("key", key).put("version", 0).put("on", false) + .put("offVariation", 0).put("variations", LDValue.buildArray().add(flagValues.get(key)).build()).build(); + flagItems.add(new AbstractMap.SimpleEntry<>(key, FEATURES.deserialize(flag.toJsonString()))); + } + List> segmentItems = new ArrayList<>(); + for (String key : segments.keys()) { + segmentItems.add(new AbstractMap.SimpleEntry<>(key, SEGMENTS.deserialize(segments.get(key).toJsonString()))); + } + List>> result = new ArrayList<>(); + result.add(new AbstractMap.SimpleEntry<>(FEATURES, new KeyedItems<>(flagItems))); + result.add(new AbstractMap.SimpleEntry<>(SEGMENTS, new KeyedItems<>(segmentItems))); + return result; + } + + @Test + public void vectorsFileHasSupportedSchema() { + LDValue file = loadVectors(); + assertEquals(SUPPORTED_SCHEMA_VERSION, file.get("schemaVersion").stringValue()); + assertTrue(file.get("vectors").size() > 0); + } + + @Test + public void allVectorsPass() throws Exception { + LDValue file = loadVectors(); + List failures = new ArrayList<>(); + for (LDValue vector : file.get("vectors").values()) { + String name = vector.get("group").stringValue() + ": " + vector.get("description").stringValue(); + try { + runVector(vector); + } catch (AssertionError e) { + failures.add(name + " -> " + e.getMessage()); + } + } + assertTrue("failing vectors:\n" + String.join("\n", failures), failures.isEmpty()); + } + + private void runVector(LDValue vector) throws Exception { + LDValue ldData = vector.get("launchDarklyData"); + boolean initialized = ldData.get("initialized").booleanValue(); + LDValue overrides = vector.get("overrides"); + TestOverrideSource source = new TestOverrideSource(collectionsFromJson( + overrides.get("flags"), overrides.get("flagValues"), overrides.get("segments"))); + + DataSystemBuilder dataSystem = Components.dataSystem().custom().overrides(source); + LDConfig.Builder config = new LDConfig.Builder() + .events(Components.noEvents()) + .logging(Components.logging(testLogging).level(LDLogLevel.DEBUG)); + if (initialized) { + dataSystem.initializers(initializerWith(collectionsFromJson( + ldData.get("flags"), LDValue.buildObject().build(), ldData.get("segments")))); + } else { + // With no sources at all, the client would consider itself initialized with empty data rather + // than apply its not-initialized handling. A synchronizer that never delivers anything avoids + // that. + dataSystem.synchronizers(hangingSynchronizer()); + config.startWait(Duration.ZERO); + } + config.dataSystem(dataSystem); + + LDValue evaluate = vector.get("evaluate"); + LDContext context = JsonSerialization.deserialize(evaluate.get("context").toJsonString(), LDContext.class); + LDValue expect = vector.get("expect"); + + try (LDClient client = new LDClient("sdk-key", config.build())) { + assertEquals("initialization state", initialized, client.isInitialized()); + EvaluationDetail detail = client.jsonValueVariationDetail( + evaluate.get("flagKey").stringValue(), context, evaluate.get("defaultValue")); + + assertEquals("value", expect.get("value"), detail.getValue()); + if (expect.get("variationIndex").isNull()) { + assertTrue("variationIndex should be undefined", detail.isDefaultValue()); + } else { + assertEquals("variationIndex", expect.get("variationIndex").intValue(), detail.getVariationIndex()); + } + assertVectorReason(expect.get("reason"), LDValue.parse(JsonSerialization.serialize(detail.getReason()))); + } + } + + // Compares the actual reason against only the fields present in the expected reason. The + // override indicator collapses tri-state: an expected reason that omits it requires the actual + // reason to report false, which is never serialized, or to omit it. + private static void assertVectorReason(LDValue expected, LDValue actual) { + for (String field : expected.keys()) { + assertEquals("reason field " + field, expected.get(field), actual.get(field)); + } + if (expected.get("overrideAffected").isNull()) { + assertFalse("overrideAffected must be false or omitted", actual.get("overrideAffected").booleanValue()); + } + } +} diff --git a/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/OverrideTestDataSources.java b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/OverrideTestDataSources.java new file mode 100644 index 00000000..2499bc3a --- /dev/null +++ b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/OverrideTestDataSources.java @@ -0,0 +1,76 @@ +package com.launchdarkly.sdk.server; + +import com.launchdarkly.sdk.fdv2.ChangeSet; +import com.launchdarkly.sdk.fdv2.ChangeSetType; +import com.launchdarkly.sdk.fdv2.Selector; +import com.launchdarkly.sdk.server.datasources.FDv2SourceResult; +import com.launchdarkly.sdk.server.datasources.Initializer; +import com.launchdarkly.sdk.server.datasources.Synchronizer; +import com.launchdarkly.sdk.server.subsystems.DataSourceBuilder; +import com.launchdarkly.sdk.server.subsystems.DataStoreTypes.DataKind; +import com.launchdarkly.sdk.server.subsystems.DataStoreTypes.ItemDescriptor; +import com.launchdarkly.sdk.server.subsystems.DataStoreTypes.KeyedItems; + +import java.util.Map; +import java.util.concurrent.CompletableFuture; + +/** + * FDv2 data sources for tests of the override layer: an initializer that supplies fixed + * LaunchDarkly data as a full transfer, and a synchronizer that never supplies anything so the + * client never initializes. + */ +@SuppressWarnings("javadoc") +abstract class OverrideTestDataSources { + private OverrideTestDataSources() {} + + /** + * Returns an initializer that supplies the given data as a full transfer with a selector, which + * is what makes the client report that it is initialized. + */ + static DataSourceBuilder initializerWith( + final Iterable>> data) { + return context -> new Initializer() { + @Override + public String name() { + return "TestInitializer"; + } + + @Override + public CompletableFuture run() { + ChangeSet>>> changeSet = new ChangeSet<>( + ChangeSetType.Full, Selector.make(1, "test-state"), data, null, false); + return CompletableFuture.completedFuture(FDv2SourceResult.changeSet(changeSet, false)); + } + + @Override + public void close() { + } + }; + } + + /** + * Returns a synchronizer that never delivers anything. With no sources at all the client would + * consider itself initialized with empty data. A synchronizer that never delivers keeps the + * client in its not-initialized state. + */ + static DataSourceBuilder hangingSynchronizer() { + return context -> new Synchronizer() { + private final CompletableFuture shutdown = new CompletableFuture<>(); + + @Override + public String name() { + return "HangingSynchronizer"; + } + + @Override + public CompletableFuture next() { + return shutdown; + } + + @Override + public void close() { + shutdown.complete(FDv2SourceResult.shutdown()); + } + }; + } +} diff --git a/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/TestOverrideSource.java b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/TestOverrideSource.java new file mode 100644 index 00000000..02af3be2 --- /dev/null +++ b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/TestOverrideSource.java @@ -0,0 +1,61 @@ +package com.launchdarkly.sdk.server; + +import com.launchdarkly.sdk.server.subsystems.ClientContext; +import com.launchdarkly.sdk.server.subsystems.ComponentConfigurer; +import com.launchdarkly.sdk.server.subsystems.DataStoreTypes.DataKind; +import com.launchdarkly.sdk.server.subsystems.DataStoreTypes.ItemDescriptor; +import com.launchdarkly.sdk.server.subsystems.DataStoreTypes.KeyedItems; +import com.launchdarkly.sdk.server.subsystems.OverrideSink; +import com.launchdarkly.sdk.server.subsystems.OverrideSource; + +import java.util.Map; +import java.util.concurrent.atomic.AtomicBoolean; + +/** + * A test override source. It supplies its initial data when started and forwards later + * replacements to the sink, as if the source's backing data had changed. + */ +@SuppressWarnings("javadoc") +public final class TestOverrideSource implements OverrideSource, ComponentConfigurer { + private final Iterable>> initialData; + private volatile OverrideSink sink; + final AtomicBoolean started = new AtomicBoolean(false); + final AtomicBoolean closed = new AtomicBoolean(false); + volatile ClientContext buildContext; + + public TestOverrideSource(Iterable>> initialData) { + this.initialData = initialData; + } + + @Override + public OverrideSource build(ClientContext clientContext) { + this.buildContext = clientContext; + return this; + } + + @Override + public void start(OverrideSink sink) { + this.sink = sink; + started.set(true); + if (initialData != null) { + sink.setOverrides(initialData); + } + } + + /** + * Replaces the override layer contents. Must be called after the source was started. + * + * @param data the complete set of override entries + */ + public void setOverrides(Iterable>> data) { + OverrideSink s = sink; + if (s != null && !closed.get()) { + s.setOverrides(data); + } + } + + @Override + public void close() { + closed.set(true); + } +} diff --git a/lib/sdk/server/src/test/resources/override-vectors/vectors.json b/lib/sdk/server/src/test/resources/override-vectors/vectors.json new file mode 100644 index 00000000..48c3347c --- /dev/null +++ b/lib/sdk/server/src/test/resources/override-vectors/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 + } + } + ] +}