From 51b497af89ca03a404942058bf5f8cb63d8ce0ac Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Fri, 25 Sep 2026 15:36:24 -0700 Subject: [PATCH] feat: Add the override layer, the store overlay, and the override source configuration The OVERRIDE specification defines an override layer: a runtime-mutable collection of flag and segment definitions, supplied by an override source as complete snapshots, that takes precedence over LaunchDarkly data on a per-key basis at the store read boundary. Overrides are not a data source. They have no effect on initialization status, data availability, or data source status, and they are never persisted. Public surface, all experimental and subject to change: DataSystemBuilder.overrides(ComponentConfigurer), DataSystemConfiguration.getOverrideSource(), and the subsystems.OverrideSource and subsystems.OverrideSink interfaces. The SDK builds the source like any other component, starts it before the data source so its initial load completes during client construction, and closes it with the client. An offline client starts no override source. A source that cannot be built fails client construction. OverrideLayer holds marked shallow copies in an immutable map swapped on each update. OverrideOverlayStore implements the read boundary with override precedence for per-key reads and enumeration, and serves the layer alone when the base store fails. OverrideSinkImpl serializes snapshot application and fires the normal flag change notifications for every flag whose merged-view evaluation may have changed. The not-initialized short-circuit consults the layer first, so a flag that the layer holds is served before the client has LaunchDarkly data. The all-flags state does the same and presents an override-affected flag with event tracking off. The specification's test vectors run as a unit test through the full client stack. --- .../launchdarkly/sdk/server/DataSystem.java | 9 + .../sdk/server/FDv1DataSystem.java | 5 + .../sdk/server/FDv2DataSystem.java | 59 ++- .../sdk/server/FeatureFlagsState.java | 10 +- .../sdk/server/InputValidatingEvaluator.java | 34 +- .../com/launchdarkly/sdk/server/LDClient.java | 3 +- .../sdk/server/OverrideLayer.java | 133 ++++++ .../sdk/server/OverrideOverlayStore.java | 94 +++++ .../sdk/server/OverrideSinkImpl.java | 194 +++++++++ .../integrations/DataSystemBuilder.java | 38 +- .../subsystems/DataSystemConfiguration.java | 45 +- .../sdk/server/subsystems/OverrideSink.java | 36 ++ .../sdk/server/subsystems/OverrideSource.java | 36 ++ .../InputValidatingEvaluatorOverrideTest.java | 2 +- .../sdk/server/LDClientOverridesTest.java | 383 ++++++++++++++++++ .../sdk/server/OverrideLayerTest.java | 135 ++++++ .../sdk/server/OverrideOverlayStoreTest.java | 180 ++++++++ .../sdk/server/OverrideSinkImplTest.java | 194 +++++++++ .../sdk/server/OverrideSpecVectorsTest.java | 152 +++++++ .../sdk/server/OverrideTestDataSources.java | 76 ++++ .../sdk/server/TestOverrideSource.java | 61 +++ .../resources/override-vectors/vectors.json | 316 +++++++++++++++ 22 files changed, 2178 insertions(+), 17 deletions(-) create mode 100644 lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/OverrideLayer.java create mode 100644 lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/OverrideOverlayStore.java create mode 100644 lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/OverrideSinkImpl.java create mode 100644 lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/subsystems/OverrideSink.java create mode 100644 lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/subsystems/OverrideSource.java create mode 100644 lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/LDClientOverridesTest.java create mode 100644 lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/OverrideLayerTest.java create mode 100644 lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/OverrideOverlayStoreTest.java create mode 100644 lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/OverrideSinkImplTest.java create mode 100644 lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/OverrideSpecVectorsTest.java create mode 100644 lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/OverrideTestDataSources.java create mode 100644 lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/TestOverrideSource.java create mode 100644 lib/sdk/server/src/test/resources/override-vectors/vectors.json 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 + } + } + ] +}