Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@
* 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.
* <p>
* The layer never holds a deleted item. Serving one would hide the LaunchDarkly data for its key,
* but an override only adds or replaces definitions, so a replacement that contains one is rejected.
* <p>
* This class is package-private and should not be used by application code.
*/
final class OverrideLayer implements DataModelDependencies.ExternalDependencies {
Expand Down Expand Up @@ -50,11 +53,34 @@ static final class Replacement {
// segments. Rebuilt with the contents on every replacement and swapped with them.
private volatile DataModelDependencies.DependencyTracker dependencies = new DataModelDependencies.DependencyTracker();

/**
* Thrown by {@link OverrideLayer#setAll(Iterable)} when the data contains a deleted item. The layer
* never holds one, so the data is rejected as a whole and the layer is unchanged.
*/
static final class DeletedItemException extends IllegalArgumentException {
private static final long serialVersionUID = 1L;

DeletedItemException(DataKind kind, String key) {
super(describeKind(kind) + " \"" + key + "\" is marked as deleted");
}

private static String describeKind(DataKind kind) {
if (kind == DataModel.FEATURES) {
return "flag";
}
if (kind == DataModel.SEGMENTS) {
return "segment";
}
return "item of kind " + (kind == null ? "null" : kind.getName());
}
}

/**
* 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
* @throws DeletedItemException if the data contains a deleted item; the layer is unchanged
*/
Replacement setAll(Iterable<Map.Entry<DataKind, KeyedItems<ItemDescriptor>>> data) {
Map<DataKind, Map<String, ItemDescriptor>> collected = new HashMap<>();
Expand All @@ -69,6 +95,11 @@ Replacement setAll(Iterable<Map.Entry<DataKind, KeyedItems<ItemDescriptor>>> dat
continue;
}
for (Map.Entry<String, ItemDescriptor> item : kindItems) {
// Nothing is swapped in until every entry is collected, so a rejection leaves the layer as
// it was.
if (isDeleted(item.getValue())) {
throw new DeletedItemException(kindEntry.getKey(), item.getKey());
}
ItemDescriptor copied = markedCopy(item.getValue());
items.put(item.getKey(), copied);
tracker.updateDependenciesFrom(kindEntry.getKey(), item.getKey(), copied);
Expand Down Expand Up @@ -137,8 +168,20 @@ boolean isEmpty() {
return !nonEmpty;
}

// Reports whether a descriptor is a deleted item: the placeholder with no item, which
// DataKind.deserialize produces for a definition marked as deleted, or a flag or segment that is
// marked as deleted.
private static boolean isDeleted(ItemDescriptor item) {
if (item == null) {
return false;
}
Object entity = item.getItem();
return entity == null
|| (entity instanceof DataModel.VersionedData && ((DataModel.VersionedData) entity).isDeleted());
}

// 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.
// type is returned as is.
static ItemDescriptor markedCopy(ItemDescriptor item) {
Object entity = item.getItem();
if (entity instanceof FeatureFlag) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,14 +61,22 @@ final class OverrideSinkImpl implements OverrideSink {
* 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.
* <p>
* A snapshot that contains a deleted item is rejected as a whole: the error is logged, the layer
* keeps its contents, and no listener is notified.
*/
@Override
public synchronized void setOverrides(Iterable<Map.Entry<DataKind, KeyedItems<ItemDescriptor>>> data) {
if (closed) {
logger.debug("Ignoring an override snapshot supplied after the override source was closed");
return;
}
apply(data);
try {
apply(data);
} catch (OverrideLayer.DeletedItemException e) {
logger.error("Rejected an override snapshot because {}; the overrides in effect are unchanged",
e.getMessage());
}
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,8 @@ private FileOverrides() {}
* override. A configured file that does not exist contributes no overrides, so deleting every file
* removes them all. A file that exists but cannot be read or parsed makes the reload fail: the
* overrides in effect stay, the source logs the failure, and it recovers on its own once the file
* can be read again.
* can be read again. A file that contains an entry marked as deleted fails to load in the same way;
* to remove an override, remove its entry or delete the file.
* <p>
* Files can be rewritten in place: if the source finds a file empty while it is being rewritten, it
* keeps the overrides in effect until the new content is written. To remove a file's overrides,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,9 @@
* This loader is separate from the file data source's loader because the override source has
* different rules: a configured file that does not exist contributes no entries, entries keep the
* versions that the documents specify, a value-only entry becomes a flag that is on and serves the
* value by fallthrough, the format of a file is chosen by its extension, and every failure to read or
* parse a file is reported as a file data error. The file data source keeps its own behavior.
* value by fallthrough, the format of a file is chosen by its extension, a flag or segment entry that
* is marked as deleted fails the load, and every failure to read or parse a file is reported as a file
* data error. The file data source keeps its own behavior.
* <p>
* Whether a file with no content is valid depends on its format, like the rest of its content. A JSON
* file with no content is not a JSON document, so it fails to parse. A YAML file with no content is a
Expand Down Expand Up @@ -426,7 +427,8 @@ private int[] addEntries(Map<DataKind, Map<String, ItemDescriptor>> data, Path p
try {
if (document.flags != null) {
for (Map.Entry<String, LDValue> e : document.flags.entrySet()) {
if (add(data, FEATURES, e.getKey(), FlagFactory.flagFromJson(e.getValue()))) {
ItemDescriptor flag = notDeleted("flag", e.getKey(), FlagFactory.flagFromJson(e.getValue()));
if (add(data, FEATURES, e.getKey(), flag)) {
flagsAdded++;
}
}
Expand All @@ -440,7 +442,8 @@ private int[] addEntries(Map<DataKind, Map<String, ItemDescriptor>> data, Path p
}
if (document.segments != null) {
for (Map.Entry<String, LDValue> e : document.segments.entrySet()) {
if (add(data, SEGMENTS, e.getKey(), FlagFactory.segmentFromJson(e.getValue()))) {
ItemDescriptor segment = notDeleted("segment", e.getKey(), FlagFactory.segmentFromJson(e.getValue()));
if (add(data, SEGMENTS, e.getKey(), segment)) {
segmentsAdded++;
}
}
Expand All @@ -456,6 +459,18 @@ private int[] addEntries(Map<DataKind, Map<String, ItemDescriptor>> data, Path p
return new int[] { flagsAdded, segmentsAdded };
}

// A definition marked as deleted deserializes to a deleted item placeholder. In an override file it
// is a mistake, not a way to remove an override, which is done by removing the entry, so it fails the
// load like a file that cannot be parsed. It fails even when the duplicate keys handling would drop
// the entry, because the file still needs correcting. The file data source keeps accepting it.
private static ItemDescriptor notDeleted(String kind, String key, ItemDescriptor item) throws FileDataException {
if (item.getItem() == null) {
throw new FileDataException(kind + " \"" + key + "\" is marked as deleted; remove the entry to remove its override",
null);
}
return item;
}

/**
* Records that the result of a load is now the data in effect: it was applied, or it was skipped
* because it is unchanged. Whether each file contributed content to the data in effect decides
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,9 @@ public interface OverrideSink {
* 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.
* itself marks the entries as overrides. The source's objects are never modified. A snapshot that
* contains a deleted flag or segment is rejected as a whole: the SDK logs an error and the overrides
* in effect stay.
* <p>
* 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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
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 OverrideLayerTest {
Expand Down Expand Up @@ -119,13 +120,64 @@ public void emptyOrNullSnapshotClearsLayer() {
assertTrue(layer.isEmpty());
}

static Iterable<Map.Entry<DataKind, KeyedItems<ItemDescriptor>>> withItem(
Iterable<Map.Entry<DataKind, KeyedItems<ItemDescriptor>>> data, DataKind kind, String key, ItemDescriptor item) {
List<Map.Entry<DataKind, KeyedItems<ItemDescriptor>>> result = new ArrayList<>();
for (Map.Entry<DataKind, KeyedItems<ItemDescriptor>> e : data) {
result.add(e);
}
result.add(new AbstractMap.SimpleEntry<>(kind,
new KeyedItems<>(Collections.singletonList(new AbstractMap.SimpleEntry<>(key, item)))));
return result;
}

private static void assertRejected(OverrideLayer layer,
Iterable<Map.Entry<DataKind, KeyedItems<ItemDescriptor>>> data, String expectedMessage) {
try {
layer.setAll(data);
fail("expected the replacement to be rejected");
} catch (OverrideLayer.DeletedItemException e) {
assertEquals(expectedMessage, e.getMessage());
}
}

@Test
public void replacementThatContainsADeletedItemIsRejectedAndTheLayerIsUnchanged() {
OverrideLayer layer = new OverrideLayer();
layer.setAll(flagsOnly(flagBuilder("kept").version(1).build()));
ItemDescriptor before = layer.get(FEATURES, "kept");

Iterable<Map.Entry<DataKind, KeyedItems<ItemDescriptor>>> otherEntries =
flagsOnly(flagBuilder("kept").version(2).build(), flagBuilder("added").build());
// The placeholder that DataKind.deserialize produces for a definition marked as deleted.
assertRejected(layer, withItem(otherEntries, FEATURES, "gone",
FEATURES.deserialize("{\"key\":\"gone\",\"version\":3,\"deleted\":true}")),
"flag \"gone\" is marked as deleted");
assertRejected(layer, withItem(otherEntries, SEGMENTS, "gone-segment",
SEGMENTS.deserialize("{\"key\":\"gone-segment\",\"version\":3,\"deleted\":true}")),
"segment \"gone-segment\" is marked as deleted");
// A model object that is marked as deleted.
assertRejected(layer, withItem(otherEntries, FEATURES, "gone",
new ItemDescriptor(3, flagBuilder("gone").version(3).deleted(true).build())),
"flag \"gone\" is marked as deleted");
assertRejected(layer, withItem(otherEntries, SEGMENTS, "gone-segment",
new ItemDescriptor(3, segmentBuilder("gone-segment").version(3).deleted(true).build())),
"segment \"gone-segment\" is marked as deleted");

assertSame(before, layer.get(FEATURES, "kept"));
assertNull(layer.get(FEATURES, "added"));
assertNull(layer.get(FEATURES, "gone"));
assertNull(layer.get(SEGMENTS, "gone-segment"));
assertEquals(1, layer.all(FEATURES).size());
assertTrue(layer.all(SEGMENTS).isEmpty());
}

@Test
public void deletedPlaceholderIsStoredAsIs() {
public void definitionWithDeletedFalseIsStored() {
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"));
layer.setAll(withItem(flagsOnly(), FEATURES, "f",
FEATURES.deserialize("{\"key\":\"f\",\"version\":1,\"deleted\":false}")));
assertTrue(((FeatureFlag) layer.get(FEATURES, "f").getItem()).isOverride());
}

@Test
Expand Down
Original file line number Diff line number Diff line change
@@ -1,16 +1,23 @@
package com.launchdarkly.sdk.server;

import com.launchdarkly.logging.LDLogger;
import com.launchdarkly.logging.LogCapture;
import com.launchdarkly.logging.Logs;
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 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.Collections;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.LinkedBlockingQueue;
Expand All @@ -23,11 +30,13 @@
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.OverrideLayerTest.withItem;
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.assertNotNull;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertSame;
import static org.junit.Assert.assertTrue;

@SuppressWarnings("javadoc")
Expand All @@ -36,7 +45,9 @@ public class OverrideSinkImplTest {
private final OverrideLayer layer = new OverrideLayer();
private final EventBroadcasterImpl<FlagChangeListener, FlagChangeEvent> broadcaster =
EventBroadcasterImpl.forFlagChangeEvents(sharedExecutor, nullLogger);
private final OverrideSinkImpl sink = new OverrideSinkImpl(layer, base, broadcaster, nullLogger);
private final LogCapture logCapture = Logs.capture();
private final OverrideSinkImpl sink = new OverrideSinkImpl(layer, base, broadcaster,
LDLogger.withAdapter(logCapture, ""));
private final BlockingQueue<FlagChangeEvent> events = new LinkedBlockingQueue<>();

private void listen() {
Expand Down Expand Up @@ -226,4 +237,66 @@ public void valueChangeInSameVersionIsDetectedThroughSerializedForm() throws Exc
sink.setOverrides(flagsOnly(flagBuilder("a").version(1).variations(LDValue.of("y")).build()));
assertEquals(keys("a"), awaitKeys(1));
}

private void assertRejectedSnapshotLogged(String kind, String key) {
assertTrue(logCapture.getMessageStrings().toString(), logCapture.getMessageStrings().contains(
"ERROR:Rejected an override snapshot because " + kind + " \"" + key
+ "\" is marked as deleted; the overrides in effect are unchanged"));
}

@Test
public void snapshotWithADeletedFlagIsRejected() throws Exception {
listen();
sink.setOverrides(flagsOnly(flagBuilder("a").version(1).build()));
assertEquals(keys("a"), awaitKeys(1));
ItemDescriptor before = layer.get(DataModel.FEATURES, "a");

// The other entries of the snapshot would each be a change.
Iterable<Map.Entry<DataKind, KeyedItems<ItemDescriptor>>> snapshot = withItem(
flagsOnly(flagBuilder("a").version(2).build(), flagBuilder("b").build()),
DataModel.FEATURES, "gone", DataModel.FEATURES.deserialize("{\"key\":\"gone\",\"version\":3,\"deleted\":true}"));
sink.setOverrides(snapshot);

assertSame(before, layer.get(DataModel.FEATURES, "a"));
assertNull(layer.get(DataModel.FEATURES, "b"));
assertNull(layer.get(DataModel.FEATURES, "gone"));
assertNoMoreEvents();
assertRejectedSnapshotLogged("flag", "gone");

// The sink still applies a later snapshot.
sink.setOverrides(flagsOnly(flagBuilder("a").version(2).build()));
assertEquals(keys("a"), awaitKeys(1));
}

@Test
public void snapshotWithADeletedSegmentIsRejected() throws Exception {
// A LaunchDarkly flag depends on the deleted segment, so applying it would notify that flag.
base.withFlag(flagBuilder("uses-segment").on(true)
.rules(ruleBuilder().id("r").variation(0).clauses(clauseMatchingSegment("seg")).build()).build());
listen();
sink.setOverrides(collections(Collections.<FeatureFlag>emptyList(),
Collections.singletonList(segmentBuilder("other").version(1).build())));
assertNoMoreEvents();
ItemDescriptor before = layer.get(DataModel.SEGMENTS, "other");

sink.setOverrides(withItem(flagsOnly(flagBuilder("a").build()), DataModel.SEGMENTS, "seg",
DataModel.SEGMENTS.deserialize("{\"key\":\"seg\",\"version\":3,\"deleted\":true}")));

assertSame(before, layer.get(DataModel.SEGMENTS, "other"));
assertNull(layer.get(DataModel.SEGMENTS, "seg"));
assertNull(layer.get(DataModel.FEATURES, "a"));
assertNoMoreEvents();
assertRejectedSnapshotLogged("segment", "seg");
}

@Test
public void snapshotWithADeletedItemIsRejectedWhenNothingIsListening() {
sink.setOverrides(flagsOnly(flagBuilder("a").version(1).build()));
sink.setOverrides(withItem(flagsOnly(flagBuilder("b").build()), DataModel.FEATURES, "gone",
ItemDescriptor.deletedItem(3)));

assertEquals(1, layer.get(DataModel.FEATURES, "a").getVersion());
assertNull(layer.get(DataModel.FEATURES, "b"));
assertRejectedSnapshotLogged("flag", "gone");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import com.launchdarkly.sdk.server.subsystems.DataStoreTypes.ItemDescriptor;
import com.launchdarkly.sdk.server.subsystems.DataStoreTypes.KeyedItems;
import com.launchdarkly.testhelpers.JsonTestValue;
import com.launchdarkly.testhelpers.TempFile;

import org.junit.Assert;
import org.junit.Test;
Expand Down Expand Up @@ -151,6 +152,22 @@ public void duplicateKeysCanBeAllowed() throws Exception {
jsonIncludes("{\"included\":[\"user1\"]}")); // value from first file
}

@Test
public void entriesMarkedAsDeletedStillLoadAsDeletedItems() throws Exception {
// The file override source rejects such entries. The file data source keeps accepting them, as
// it always has; rejecting them here would be a breaking change.
try (TempFile f = TempFile.create()) {
f.setContents("{\"flags\":{\"gone\":{\"key\":\"gone\",\"deleted\":true}},"
+ "\"segments\":{\"gone-segment\":{\"key\":\"gone-segment\",\"deleted\":true}}}");
DataLoader ds = new DataLoader(FileData.dataSource().filePaths(f.getPath()).sources);
int version = ds.load(builder);

Map<DataKind, Map<String, ItemDescriptor>> data = toDataMap(new FullDataSet<>(builder.build()));
assertThat(data.get(FEATURES).get("gone"), equalTo(ItemDescriptor.deletedItem(version)));
assertThat(data.get(SEGMENTS).get("gone-segment"), equalTo(ItemDescriptor.deletedItem(version)));
}
}

@Test
public void versionsAreIncrementedForEachLoad() throws Exception {
DataLoader ds = new DataLoader(FileData.dataSource().filePaths(
Expand Down
Loading
Loading