Skip to content
Draft
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
2 changes: 1 addition & 1 deletion lib/sdk/server/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ ext.versions = [
"gson": "2.13.1",
"guava": "32.0.1-jre",
"jackson": "2.11.2",
"launchdarklyJavaSdkCommon": "2.3.0",
"launchdarklyJavaSdkCommon": "2.6.0",
"launchdarklyJavaSdkInternal": "1.11.1",
"launchdarklyLogging": "1.1.0",
"okhttp": "4.12.0", // specify this for the SDK build instead of relying on the transitive dependency from okhttp-eventsource
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,15 @@ static final class FeatureFlag implements VersionedData, JsonHelpers.PostProcess
private Migration migration;
private boolean excludeFromSummaries;

// True if this definition came from the SDK's override store rather than from LaunchDarkly. The
// field is transient so that it never appears in the JSON form of the flag and never takes part
// in deserialization. Only the override layer sets it, on a copy that it owns. Evaluation reads
// it to mark the evaluation as override-affected. Other readers treat a marked flag the same as
// any other flag.
//
// Flag overrides are currently experimental and subject to change.
private transient boolean isOverride;

/**
* Container for migration specific flag data.
*/
Expand Down Expand Up @@ -267,6 +276,31 @@ boolean isExcludeFromSummaries() {
return excludeFromSummaries;
}

/**
* Returns true if this definition came from the override store.
*
* @return true for an override entry
*/
boolean isOverride() {
return isOverride;
}

/**
* Returns a shallow copy of this flag that carries the override marker. The copy shares its
* nested lists and its preprocessing data with this flag and never writes to them. This flag is
* not modified.
*
* @return a marked copy
*/
FeatureFlag markedAsOverride() {
FeatureFlag copy = new FeatureFlag(key, version, on, prerequisites, salt, targets, contextTargets, rules,
fallthrough, offVariation, variations, clientSide, trackEvents, trackEventsFallthrough,
debugEventsUntilDate, deleted, samplingRatio, migration, excludeFromSummaries);
copy.preprocessed = preprocessed;
copy.isOverride = true;
return copy;
}

public void afterDeserialized() {
DataModelPreprocessing.preprocessFlag(this);
}
Expand Down Expand Up @@ -507,6 +541,10 @@ static final class Segment implements VersionedData, JsonHelpers.PostProcessingD
private ContextKind unboundedContextKind;
private Integer generation;

// True if this definition came from the SDK's override store rather than from LaunchDarkly. See
// the note on the same field in FeatureFlag.
private transient boolean isOverride;

Segment() {}

Segment(String key,
Expand Down Expand Up @@ -588,6 +626,28 @@ public Integer getGeneration() {
return generation;
}

/**
* Returns true if this definition came from the override store.
*
* @return true for an override entry
*/
boolean isOverride() {
return isOverride;
}

/**
* Returns a shallow copy of this segment that carries the override marker. The copy shares its
* nested collections with this segment and never writes to them. This segment is not modified.
*
* @return a marked copy
*/
Segment markedAsOverride() {
Segment copy = new Segment(key, included, excluded, includedContexts, excludedContexts, salt, rules,
version, deleted, unbounded, unboundedContextKind, generation);
copy.isOverride = true;
return copy;
}

public void afterDeserialized() {
DataModelPreprocessing.preprocessSegment(this);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,14 @@ public EvaluationDetail<String> getAsString() {
*/
public boolean isForceReasonTracking() { return forceReasonTracking; }

/**
* Returns true if an override affected this evaluation, directly or transitively. The value is
* the reason's indicator, so the result and the reason it returns to the caller always agree.
* Flag overrides are currently experimental and subject to change.
* @return true if an override affected the evaluation
*/
public boolean isOverrideAffected() { return anyType.getReason().isOverrideAffected(); }

public List<PrerequisiteEvalRecord> getPrerequisiteEvalRecords() { return prerequisiteEvalRecords; }

/**
Expand All @@ -251,6 +259,17 @@ public EvalResult withForceReasonTracking(boolean newValue) {
public EvalResult withPrerequisiteEvalRecords(List<PrerequisiteEvalRecord> newValue) {
return this.prerequisiteEvalRecords == newValue ? this : new EvalResult(this, newValue);
}

/**
* Returns a transformed copy of this EvalResult whose reason carries the given override-affected
* indicator, or this same instance if the indicator is unchanged. Precomputed results are shared
* between evaluations, so a marked result is always a new instance.
* @param newValue the new value for the indicator
* @return a transformed copy
*/
public EvalResult withOverrideAffected(boolean newValue) {
return withReason(anyType.getReason().withOverrideAffected(newValue));
}

@Override
public boolean equals(Object other) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,12 @@ private static class EvaluatorState {
private List<String> prerequisiteStack = null;
private List<PrerequisiteEvalRecord> prerequisiteEvalRecords = new ArrayList<>(0); // 0 initial capacity uses a static instance for performance
private List<String> segmentStack = null;
// True if the current evaluation scope has read a definition that carries the override marker.
// The scope starts from its own flag's marker. Each segment read can set it. Around a
// prerequisite evaluation the value is saved and reset, so the prerequisite's record reflects
// only the definitions that its own subtree read, and the parent scope accumulates that result
// afterwards. The marking therefore propagates upward only.
private boolean overrideAffected = false;
}

Evaluator(Getters getters, LDLogger logger) {
Expand All @@ -146,6 +152,8 @@ EvalResult evaluate(FeatureFlag flag, LDContext context, @Nonnull EvaluationReco

EvaluatorState state = new EvaluatorState();
state.originalFlag = flag;
// Reading the flag's own definition is the first read of this scope.
state.overrideAffected = flag.isOverride();

try {
EvalResult result = evaluateInternal(flag, context, recorder, state);
Expand All @@ -160,10 +168,12 @@ EvalResult evaluate(FeatureFlag flag, LDContext context, @Nonnull EvaluationReco
result = result.withPrerequisiteEvalRecords(state.prerequisiteEvalRecords);
}

return result;
return result.withOverrideAffected(state.overrideAffected);
} catch (EvaluationException e) {
logger.error("Could not evaluate flag \"{}\": {}", flag.getKey(), e.getMessage());
return EvalResult.error(e.errorKind);
// An error result is marked too. A malformed override definition yields the caller's default
// value with an error reason, and an override still affected that result.
return EvalResult.error(e.errorKind).withOverrideAffected(state.overrideAffected);
}
}

Expand Down Expand Up @@ -246,7 +256,20 @@ private EvalResult checkPrerequisites(FeatureFlag flag, LDContext context, @Nonn
logger.error("Could not retrieve prerequisite flag \"{}\" when evaluating \"{}\"", prereq.getKey(), flag.getKey());
prereqOk = false;
} else {
EvalResult prereqEvalResult = evaluateInternal(prereqFeatureFlag, context, recorder, state);
// The prerequisite evaluation is a scope of its own. Its marking starts from its own flag's
// marker, so its record reflects only the definitions that its subtree read. This scope
// accumulates that result afterwards, whether the nested evaluation returns or throws.
boolean parentOverrideAffected = state.overrideAffected;
state.overrideAffected = prereqFeatureFlag.isOverride();
EvalResult prereqEvalResult;
boolean prereqOverrideAffected;
try {
prereqEvalResult = evaluateInternal(prereqFeatureFlag, context, recorder, state);
} finally {
prereqOverrideAffected = state.overrideAffected;
state.overrideAffected = parentOverrideAffected || prereqOverrideAffected;
}
prereqEvalResult = prereqEvalResult.withOverrideAffected(prereqOverrideAffected);
// Note that if the prerequisite flag is off, we don't consider it a match no matter what its
// off variation was. But we still need to evaluate it in order to generate an event.
if (!prereqFeatureFlag.isOn() || prereqEvalResult.getVariationIndex() != prereq.getVariation()) {
Expand Down Expand Up @@ -461,6 +484,12 @@ private boolean matchAnySegment(List<LDValue> values, LDContext context, Evaluat
}
Segment segment = getters.getSegment(segmentKey);
if (segment != null) {
// The segment definition is read at this point, so an override segment marks the scope here.
// A match is not required. A negated clause turns a non-match into a match, so the definition
// shapes the result either way.
if (segment.isOverride()) {
state.overrideAffected = true;
}
if (segmentMatchesContext(segment, context, state)) {
return true;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,11 @@ EvalResultAndFlag evaluate(String flagKey, LDContext context, LDValue defaultVal
value.getType() != requireType) {
logger.error("Feature flag \"{}\"; evaluation expected result as {}, but got {}", flagKey, defaultValue.getType(), value.getType());
recorder.recordEvaluationError(featureFlag, context, defaultValue, ErrorKind.WRONG_TYPE);
return new EvalResultAndFlag(EvalResult.error(ErrorKind.WRONG_TYPE, defaultValue), featureFlag);
// The type mismatch replaces the reason. The evaluation read the same definitions, so the
// new reason keeps the override-affected marking.
return new EvalResultAndFlag(
EvalResult.error(ErrorKind.WRONG_TYPE, defaultValue).withOverrideAffected(result.isOverrideAffected()),
featureFlag);
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,20 @@
import com.launchdarkly.sdk.server.DataModel.SegmentRule;
import com.launchdarkly.sdk.server.DataModel.Target;

import com.launchdarkly.sdk.LDValue;
import com.launchdarkly.sdk.server.subsystems.DataStoreTypes.ItemDescriptor;

import org.junit.Test;

import static com.launchdarkly.sdk.server.ModelBuilders.flagBuilder;
import static com.launchdarkly.sdk.server.ModelBuilders.prerequisite;
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.assertNotSame;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertSame;
import static org.junit.Assert.assertTrue;

@SuppressWarnings("javadoc")
public class DataModelTest {
Expand Down Expand Up @@ -108,4 +119,82 @@ private Segment segmentWithAllZeroValuedFields() {
// and no preprocessing has happened.
return new Segment();
}

@Test
public void flagAndSegmentAreNotOverridesByDefault() {
assertFalse(flagBuilder("f").build().isOverride());
assertFalse(segmentBuilder("s").build().isOverride());
assertFalse(((FeatureFlag) DataModel.FEATURES.deserialize("{\"key\":\"f\",\"version\":1}").getItem()).isOverride());
assertFalse(((Segment) DataModel.SEGMENTS.deserialize("{\"key\":\"s\",\"version\":1}").getItem()).isOverride());
}

@Test
public void markedFlagCopyCarriesMarkerAndSharesDataWithoutMutatingSource() {
FeatureFlag source = flagBuilder("f").version(7).on(true).variations(LDValue.of("a"), LDValue.of("b"))
.fallthroughVariation(1).offVariation(0).prerequisites(prerequisite("p", 1)).trackEvents(true)
.debugEventsUntilDate(1000L).build();

FeatureFlag marked = source.markedAsOverride();

assertTrue(marked.isOverride());
assertFalse(source.isOverride());
assertNotSame(source, marked);
assertEquals(source.getKey(), marked.getKey());
assertEquals(source.getVersion(), marked.getVersion());
assertEquals(source.isOn(), marked.isOn());
assertSame(source.getVariations(), marked.getVariations());
assertSame(source.getPrerequisites(), marked.getPrerequisites());
assertSame(source.getFallthrough(), marked.getFallthrough());
assertEquals(source.getOffVariation(), marked.getOffVariation());
assertEquals(source.isTrackEvents(), marked.isTrackEvents());
assertEquals(source.getDebugEventsUntilDate(), marked.getDebugEventsUntilDate());
assertSame(source.preprocessed, marked.preprocessed);
}

@Test
public void markedFlagCopyOfUnpreprocessedFlagHasNoPreprocessing() {
FeatureFlag source = flagBuilder("f").disablePreprocessing(true).build();
FeatureFlag marked = source.markedAsOverride();
assertNull(marked.preprocessed);
assertNull(source.preprocessed);
assertTrue(marked.isOverride());
}

@Test
public void markedSegmentCopyCarriesMarkerAndSharesDataWithoutMutatingSource() {
Segment source = segmentBuilder("s").version(3).included("u1").excluded("u2").unbounded(false).build();

Segment marked = source.markedAsOverride();

assertTrue(marked.isOverride());
assertFalse(source.isOverride());
assertNotSame(source, marked);
assertEquals(source.getKey(), marked.getKey());
assertEquals(source.getVersion(), marked.getVersion());
assertSame(source.getIncluded(), marked.getIncluded());
assertSame(source.getExcluded(), marked.getExcluded());
assertSame(source.getRules(), marked.getRules());
}

@Test
public void overrideMarkerIsNeverSerialized() {
FeatureFlag flag = flagBuilder("f").version(7).build().markedAsOverride();
String flagJson = DataModel.FEATURES.serialize(new ItemDescriptor(flag.getVersion(), flag));
assertFalse(flagJson.toLowerCase().contains("override"));
assertEquals(LDValue.of("f"), LDValue.parse(flagJson).get("key"));

Segment segment = segmentBuilder("s").version(3).build().markedAsOverride();
String segmentJson = DataModel.SEGMENTS.serialize(new ItemDescriptor(segment.getVersion(), segment));
assertFalse(segmentJson.toLowerCase().contains("override"));
}

@Test
public void overrideMarkerInJsonIsIgnoredWhenDeserializing() {
FeatureFlag flag = (FeatureFlag) DataModel.FEATURES.deserialize(
"{\"key\":\"f\",\"version\":1,\"isOverride\":true}").getItem();
assertFalse(flag.isOverride());
Segment segment = (Segment) DataModel.SEGMENTS.deserialize(
"{\"key\":\"s\",\"version\":1,\"isOverride\":true}").getItem();
assertFalse(segment.isOverride());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.equalTo;
import static org.hamcrest.Matchers.is;
import static org.hamcrest.Matchers.not;
import static org.hamcrest.Matchers.sameInstance;

@SuppressWarnings("javadoc")
Expand Down Expand Up @@ -134,6 +135,52 @@ public void withForceReasonTracking() {
assertThat(r1.getAnyType(), sameInstance(r.getAnyType()));
}

@Test
public void overrideAffectedFollowsTheReason() {
EvalResult r = EvalResult.of(SOME_VALUE, SOME_VARIATION, SOME_REASON);
assertThat(r.isOverrideAffected(), is(false));
assertThat(EvalResult.of(SOME_VALUE, SOME_VARIATION, SOME_REASON.withOverrideAffected(true)).isOverrideAffected(),
is(true));
assertThat(EvalResult.error(EvaluationReason.ErrorKind.MALFORMED_FLAG).isOverrideAffected(), is(false));
}

@Test
public void withOverrideAffected() {
EvalResult r = EvalResult.of(SOME_VALUE, SOME_VARIATION, SOME_REASON);

// Unchanged value keeps the same instance, so shared precomputed results stay shared.
assertThat(r.withOverrideAffected(false), sameInstance(r));

EvalResult marked = r.withOverrideAffected(true);
assertThat(marked, not(sameInstance(r)));
assertThat(marked.isOverrideAffected(), is(true));
assertThat(marked.getReason(), equalTo(SOME_REASON.withOverrideAffected(true)));
assertThat(marked.getValue(), equalTo(r.getValue()));
assertThat(marked.getVariationIndex(), equalTo(r.getVariationIndex()));
assertThat(marked.withOverrideAffected(true), sameInstance(marked));
assertThat(marked.withOverrideAffected(false), equalTo(r));

// Every typed view carries the marked reason.
assertThat(marked.getAsBoolean().getReason().isOverrideAffected(), is(true));
assertThat(marked.getAsInteger().getReason().isOverrideAffected(), is(true));
assertThat(marked.getAsDouble().getReason().isOverrideAffected(), is(true));
assertThat(marked.getAsString().getReason().isOverrideAffected(), is(true));
assertThat(marked.getAnyType().getReason().isOverrideAffected(), is(true));

// The original is untouched.
assertThat(r.isOverrideAffected(), is(false));
}

@Test
public void withOverrideAffectedKeepsPrerequisiteRecordsAndForceTracking() {
EvalResult r = EvalResult.of(SOME_VALUE, SOME_VARIATION, EvaluationReason.fallthrough(true))
.withPrerequisiteEvalRecords(java.util.Collections.singletonList(
new PrerequisiteEvalRecord(null, null, EvalResult.of(SOME_VALUE, SOME_VARIATION, SOME_REASON))));
EvalResult marked = r.withOverrideAffected(true);
assertThat(marked.isForceReasonTracking(), is(true));
assertThat(marked.getPrerequisiteEvalRecords(), sameInstance(r.getPrerequisiteEvalRecords()));
}

private <T> void testForType(T value, LDValue ldValue, Function<EvalResult, T> getter) {
assertThat(
getter.apply(EvalResult.of(EvaluationDetail.fromValue(ldValue, SOME_VARIATION, SOME_REASON))),
Expand Down
Loading
Loading