Skip to content

Commit ca22bc0

Browse files
committed
feat: Add the override marker to the flag and segment models and mark evaluations
The OVERRIDE specification requires an evaluation to be marked as override-affected when any definition it read carried the override marker: the evaluated flag, a prerequisite at any depth, or a segment consulted during matching, whether or not the segment matched. The marking propagates upward only. A prerequisite's own record reflects only the definitions that its own subtree read. An evaluation that fails is still marked when it read a marked definition. DataModel.FeatureFlag and DataModel.Segment carry a transient isOverride marker that is never serialized and never read from JSON, with a markedAsOverride() shallow copy that the override layer will use without modifying the source's entity. The evaluator tracks the marking per evaluation scope: the scope starts from its own flag's marker, each segment read can set it, and the value is saved, reset to the prerequisite's marker, and restored around each prerequisite evaluation. The result and every prerequisite record carry the marking on their reason. EvalResult exposes isOverrideAffected() and withOverrideAffected(), which copies rather than mutating the shared precomputed results. A requested-type mismatch keeps the marking on its error reason. The server SDK now depends on launchdarkly-java-sdk-common 2.6.0, which adds the overrideAffected indicator to EvaluationReason. CI for this change cannot pass until that version is released. Flag overrides are currently experimental and subject to change.
1 parent 0b4c68c commit ca22bc0

9 files changed

Lines changed: 702 additions & 5 deletions

File tree

‎lib/sdk/server/build.gradle‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ ext.versions = [
7070
"gson": "2.13.1",
7171
"guava": "32.0.1-jre",
7272
"jackson": "2.11.2",
73-
"launchdarklyJavaSdkCommon": "2.3.0",
73+
"launchdarklyJavaSdkCommon": "2.6.0",
7474
"launchdarklyJavaSdkInternal": "1.11.1",
7575
"launchdarklyLogging": "1.1.0",
7676
"okhttp": "4.12.0", // specify this for the SDK build instead of relying on the transitive dependency from okhttp-eventsource

‎lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/DataModel.java‎

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,15 @@ static final class FeatureFlag implements VersionedData, JsonHelpers.PostProcess
145145
private Migration migration;
146146
private boolean excludeFromSummaries;
147147

148+
// True if this definition came from the SDK's override store rather than from LaunchDarkly. The
149+
// field is transient so that it never appears in the JSON form of the flag and never takes part
150+
// in deserialization. Only the override layer sets it, on a copy that it owns. Evaluation reads
151+
// it to mark the evaluation as override-affected. Other readers treat a marked flag the same as
152+
// any other flag.
153+
//
154+
// Flag overrides are currently experimental and subject to change.
155+
private transient boolean isOverride;
156+
148157
/**
149158
* Container for migration specific flag data.
150159
*/
@@ -267,6 +276,31 @@ boolean isExcludeFromSummaries() {
267276
return excludeFromSummaries;
268277
}
269278

279+
/**
280+
* Returns true if this definition came from the override store.
281+
*
282+
* @return true for an override entry
283+
*/
284+
boolean isOverride() {
285+
return isOverride;
286+
}
287+
288+
/**
289+
* Returns a shallow copy of this flag that carries the override marker. The copy shares its
290+
* nested lists and its preprocessing data with this flag and never writes to them. This flag is
291+
* not modified.
292+
*
293+
* @return a marked copy
294+
*/
295+
FeatureFlag markedAsOverride() {
296+
FeatureFlag copy = new FeatureFlag(key, version, on, prerequisites, salt, targets, contextTargets, rules,
297+
fallthrough, offVariation, variations, clientSide, trackEvents, trackEventsFallthrough,
298+
debugEventsUntilDate, deleted, samplingRatio, migration, excludeFromSummaries);
299+
copy.preprocessed = preprocessed;
300+
copy.isOverride = true;
301+
return copy;
302+
}
303+
270304
public void afterDeserialized() {
271305
DataModelPreprocessing.preprocessFlag(this);
272306
}
@@ -507,6 +541,10 @@ static final class Segment implements VersionedData, JsonHelpers.PostProcessingD
507541
private ContextKind unboundedContextKind;
508542
private Integer generation;
509543

544+
// True if this definition came from the SDK's override store rather than from LaunchDarkly. See
545+
// the note on the same field in FeatureFlag.
546+
private transient boolean isOverride;
547+
510548
Segment() {}
511549

512550
Segment(String key,
@@ -588,6 +626,28 @@ public Integer getGeneration() {
588626
return generation;
589627
}
590628

629+
/**
630+
* Returns true if this definition came from the override store.
631+
*
632+
* @return true for an override entry
633+
*/
634+
boolean isOverride() {
635+
return isOverride;
636+
}
637+
638+
/**
639+
* Returns a shallow copy of this segment that carries the override marker. The copy shares its
640+
* nested collections with this segment and never writes to them. This segment is not modified.
641+
*
642+
* @return a marked copy
643+
*/
644+
Segment markedAsOverride() {
645+
Segment copy = new Segment(key, included, excluded, includedContexts, excludedContexts, salt, rules,
646+
version, deleted, unbounded, unboundedContextKind, generation);
647+
copy.isOverride = true;
648+
return copy;
649+
}
650+
591651
public void afterDeserialized() {
592652
DataModelPreprocessing.preprocessSegment(this);
593653
}

‎lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/EvalResult.java‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -228,6 +228,14 @@ public EvaluationDetail<String> getAsString() {
228228
*/
229229
public boolean isForceReasonTracking() { return forceReasonTracking; }
230230

231+
/**
232+
* Returns true if an override affected this evaluation, directly or transitively. The value is
233+
* the reason's indicator, so the result and the reason it returns to the caller always agree.
234+
* Flag overrides are currently experimental and subject to change.
235+
* @return true if an override affected the evaluation
236+
*/
237+
public boolean isOverrideAffected() { return anyType.getReason().isOverrideAffected(); }
238+
231239
public List<PrerequisiteEvalRecord> getPrerequisiteEvalRecords() { return prerequisiteEvalRecords; }
232240

233241
/**
@@ -251,6 +259,17 @@ public EvalResult withForceReasonTracking(boolean newValue) {
251259
public EvalResult withPrerequisiteEvalRecords(List<PrerequisiteEvalRecord> newValue) {
252260
return this.prerequisiteEvalRecords == newValue ? this : new EvalResult(this, newValue);
253261
}
262+
263+
/**
264+
* Returns a transformed copy of this EvalResult whose reason carries the given override-affected
265+
* indicator, or this same instance if the indicator is unchanged. Precomputed results are shared
266+
* between evaluations, so a marked result is always a new instance.
267+
* @param newValue the new value for the indicator
268+
* @return a transformed copy
269+
*/
270+
public EvalResult withOverrideAffected(boolean newValue) {
271+
return withReason(anyType.getReason().withOverrideAffected(newValue));
272+
}
254273

255274
@Override
256275
public boolean equals(Object other) {

‎lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/Evaluator.java‎

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,12 @@ private static class EvaluatorState {
124124
private List<String> prerequisiteStack = null;
125125
private List<PrerequisiteEvalRecord> prerequisiteEvalRecords = new ArrayList<>(0); // 0 initial capacity uses a static instance for performance
126126
private List<String> segmentStack = null;
127+
// True if the current evaluation scope has read a definition that carries the override marker.
128+
// The scope starts from its own flag's marker. Each segment read can set it. Around a
129+
// prerequisite evaluation the value is saved and reset, so the prerequisite's record reflects
130+
// only the definitions that its own subtree read, and the parent scope accumulates that result
131+
// afterwards. The marking therefore propagates upward only.
132+
private boolean overrideAffected = false;
127133
}
128134

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

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

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

163-
return result;
171+
return result.withOverrideAffected(state.overrideAffected);
164172
} catch (EvaluationException e) {
165173
logger.error("Could not evaluate flag \"{}\": {}", flag.getKey(), e.getMessage());
166-
return EvalResult.error(e.errorKind);
174+
// An error result is marked too. A malformed override definition yields the caller's default
175+
// value with an error reason, and an override still affected that result.
176+
return EvalResult.error(e.errorKind).withOverrideAffected(state.overrideAffected);
167177
}
168178
}
169179

@@ -246,7 +256,20 @@ private EvalResult checkPrerequisites(FeatureFlag flag, LDContext context, @Nonn
246256
logger.error("Could not retrieve prerequisite flag \"{}\" when evaluating \"{}\"", prereq.getKey(), flag.getKey());
247257
prereqOk = false;
248258
} else {
249-
EvalResult prereqEvalResult = evaluateInternal(prereqFeatureFlag, context, recorder, state);
259+
// The prerequisite evaluation is a scope of its own. Its marking starts from its own flag's
260+
// marker, so its record reflects only the definitions that its subtree read. This scope
261+
// accumulates that result afterwards, whether the nested evaluation returns or throws.
262+
boolean parentOverrideAffected = state.overrideAffected;
263+
state.overrideAffected = prereqFeatureFlag.isOverride();
264+
EvalResult prereqEvalResult;
265+
boolean prereqOverrideAffected;
266+
try {
267+
prereqEvalResult = evaluateInternal(prereqFeatureFlag, context, recorder, state);
268+
} finally {
269+
prereqOverrideAffected = state.overrideAffected;
270+
state.overrideAffected = parentOverrideAffected || prereqOverrideAffected;
271+
}
272+
prereqEvalResult = prereqEvalResult.withOverrideAffected(prereqOverrideAffected);
250273
// Note that if the prerequisite flag is off, we don't consider it a match no matter what its
251274
// off variation was. But we still need to evaluate it in order to generate an event.
252275
if (!prereqFeatureFlag.isOn() || prereqEvalResult.getVariationIndex() != prereq.getVariation()) {
@@ -461,6 +484,12 @@ private boolean matchAnySegment(List<LDValue> values, LDContext context, Evaluat
461484
}
462485
Segment segment = getters.getSegment(segmentKey);
463486
if (segment != null) {
487+
// The segment definition is read at this point, so an override segment marks the scope here.
488+
// A match is not required. A negated clause turns a non-match into a match, so the definition
489+
// shapes the result either way.
490+
if (segment.isOverride()) {
491+
state.overrideAffected = true;
492+
}
464493
if (segmentMatchesContext(segment, context, state)) {
465494
return true;
466495
}

‎lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/InputValidatingEvaluator.java‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,11 @@ EvalResultAndFlag evaluate(String flagKey, LDContext context, LDValue defaultVal
135135
value.getType() != requireType) {
136136
logger.error("Feature flag \"{}\"; evaluation expected result as {}, but got {}", flagKey, defaultValue.getType(), value.getType());
137137
recorder.recordEvaluationError(featureFlag, context, defaultValue, ErrorKind.WRONG_TYPE);
138-
return new EvalResultAndFlag(EvalResult.error(ErrorKind.WRONG_TYPE, defaultValue), featureFlag);
138+
// The type mismatch replaces the reason. The evaluation read the same definitions, so the
139+
// new reason keeps the override-affected marking.
140+
return new EvalResultAndFlag(
141+
EvalResult.error(ErrorKind.WRONG_TYPE, defaultValue).withOverrideAffected(result.isOverrideAffected()),
142+
featureFlag);
139143
}
140144
}
141145

‎lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/DataModelTest.java‎

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,20 @@
1111
import com.launchdarkly.sdk.server.DataModel.SegmentRule;
1212
import com.launchdarkly.sdk.server.DataModel.Target;
1313

14+
import com.launchdarkly.sdk.LDValue;
15+
import com.launchdarkly.sdk.server.subsystems.DataStoreTypes.ItemDescriptor;
16+
1417
import org.junit.Test;
1518

19+
import static com.launchdarkly.sdk.server.ModelBuilders.flagBuilder;
20+
import static com.launchdarkly.sdk.server.ModelBuilders.prerequisite;
21+
import static com.launchdarkly.sdk.server.ModelBuilders.segmentBuilder;
1622
import static org.junit.Assert.assertEquals;
23+
import static org.junit.Assert.assertFalse;
24+
import static org.junit.Assert.assertNotSame;
25+
import static org.junit.Assert.assertNull;
26+
import static org.junit.Assert.assertSame;
27+
import static org.junit.Assert.assertTrue;
1728

1829
@SuppressWarnings("javadoc")
1930
public class DataModelTest {
@@ -108,4 +119,82 @@ private Segment segmentWithAllZeroValuedFields() {
108119
// and no preprocessing has happened.
109120
return new Segment();
110121
}
122+
123+
@Test
124+
public void flagAndSegmentAreNotOverridesByDefault() {
125+
assertFalse(flagBuilder("f").build().isOverride());
126+
assertFalse(segmentBuilder("s").build().isOverride());
127+
assertFalse(((FeatureFlag) DataModel.FEATURES.deserialize("{\"key\":\"f\",\"version\":1}").getItem()).isOverride());
128+
assertFalse(((Segment) DataModel.SEGMENTS.deserialize("{\"key\":\"s\",\"version\":1}").getItem()).isOverride());
129+
}
130+
131+
@Test
132+
public void markedFlagCopyCarriesMarkerAndSharesDataWithoutMutatingSource() {
133+
FeatureFlag source = flagBuilder("f").version(7).on(true).variations(LDValue.of("a"), LDValue.of("b"))
134+
.fallthroughVariation(1).offVariation(0).prerequisites(prerequisite("p", 1)).trackEvents(true)
135+
.debugEventsUntilDate(1000L).build();
136+
137+
FeatureFlag marked = source.markedAsOverride();
138+
139+
assertTrue(marked.isOverride());
140+
assertFalse(source.isOverride());
141+
assertNotSame(source, marked);
142+
assertEquals(source.getKey(), marked.getKey());
143+
assertEquals(source.getVersion(), marked.getVersion());
144+
assertEquals(source.isOn(), marked.isOn());
145+
assertSame(source.getVariations(), marked.getVariations());
146+
assertSame(source.getPrerequisites(), marked.getPrerequisites());
147+
assertSame(source.getFallthrough(), marked.getFallthrough());
148+
assertEquals(source.getOffVariation(), marked.getOffVariation());
149+
assertEquals(source.isTrackEvents(), marked.isTrackEvents());
150+
assertEquals(source.getDebugEventsUntilDate(), marked.getDebugEventsUntilDate());
151+
assertSame(source.preprocessed, marked.preprocessed);
152+
}
153+
154+
@Test
155+
public void markedFlagCopyOfUnpreprocessedFlagHasNoPreprocessing() {
156+
FeatureFlag source = flagBuilder("f").disablePreprocessing(true).build();
157+
FeatureFlag marked = source.markedAsOverride();
158+
assertNull(marked.preprocessed);
159+
assertNull(source.preprocessed);
160+
assertTrue(marked.isOverride());
161+
}
162+
163+
@Test
164+
public void markedSegmentCopyCarriesMarkerAndSharesDataWithoutMutatingSource() {
165+
Segment source = segmentBuilder("s").version(3).included("u1").excluded("u2").unbounded(false).build();
166+
167+
Segment marked = source.markedAsOverride();
168+
169+
assertTrue(marked.isOverride());
170+
assertFalse(source.isOverride());
171+
assertNotSame(source, marked);
172+
assertEquals(source.getKey(), marked.getKey());
173+
assertEquals(source.getVersion(), marked.getVersion());
174+
assertSame(source.getIncluded(), marked.getIncluded());
175+
assertSame(source.getExcluded(), marked.getExcluded());
176+
assertSame(source.getRules(), marked.getRules());
177+
}
178+
179+
@Test
180+
public void overrideMarkerIsNeverSerialized() {
181+
FeatureFlag flag = flagBuilder("f").version(7).build().markedAsOverride();
182+
String flagJson = DataModel.FEATURES.serialize(new ItemDescriptor(flag.getVersion(), flag));
183+
assertFalse(flagJson.toLowerCase().contains("override"));
184+
assertEquals(LDValue.of("f"), LDValue.parse(flagJson).get("key"));
185+
186+
Segment segment = segmentBuilder("s").version(3).build().markedAsOverride();
187+
String segmentJson = DataModel.SEGMENTS.serialize(new ItemDescriptor(segment.getVersion(), segment));
188+
assertFalse(segmentJson.toLowerCase().contains("override"));
189+
}
190+
191+
@Test
192+
public void overrideMarkerInJsonIsIgnoredWhenDeserializing() {
193+
FeatureFlag flag = (FeatureFlag) DataModel.FEATURES.deserialize(
194+
"{\"key\":\"f\",\"version\":1,\"isOverride\":true}").getItem();
195+
assertFalse(flag.isOverride());
196+
Segment segment = (Segment) DataModel.SEGMENTS.deserialize(
197+
"{\"key\":\"s\",\"version\":1,\"isOverride\":true}").getItem();
198+
assertFalse(segment.isOverride());
199+
}
111200
}

‎lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/EvalResultTest.java‎

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
import static org.hamcrest.MatcherAssert.assertThat;
1414
import static org.hamcrest.Matchers.equalTo;
1515
import static org.hamcrest.Matchers.is;
16+
import static org.hamcrest.Matchers.not;
1617
import static org.hamcrest.Matchers.sameInstance;
1718

1819
@SuppressWarnings("javadoc")
@@ -134,6 +135,52 @@ public void withForceReasonTracking() {
134135
assertThat(r1.getAnyType(), sameInstance(r.getAnyType()));
135136
}
136137

138+
@Test
139+
public void overrideAffectedFollowsTheReason() {
140+
EvalResult r = EvalResult.of(SOME_VALUE, SOME_VARIATION, SOME_REASON);
141+
assertThat(r.isOverrideAffected(), is(false));
142+
assertThat(EvalResult.of(SOME_VALUE, SOME_VARIATION, SOME_REASON.withOverrideAffected(true)).isOverrideAffected(),
143+
is(true));
144+
assertThat(EvalResult.error(EvaluationReason.ErrorKind.MALFORMED_FLAG).isOverrideAffected(), is(false));
145+
}
146+
147+
@Test
148+
public void withOverrideAffected() {
149+
EvalResult r = EvalResult.of(SOME_VALUE, SOME_VARIATION, SOME_REASON);
150+
151+
// Unchanged value keeps the same instance, so shared precomputed results stay shared.
152+
assertThat(r.withOverrideAffected(false), sameInstance(r));
153+
154+
EvalResult marked = r.withOverrideAffected(true);
155+
assertThat(marked, not(sameInstance(r)));
156+
assertThat(marked.isOverrideAffected(), is(true));
157+
assertThat(marked.getReason(), equalTo(SOME_REASON.withOverrideAffected(true)));
158+
assertThat(marked.getValue(), equalTo(r.getValue()));
159+
assertThat(marked.getVariationIndex(), equalTo(r.getVariationIndex()));
160+
assertThat(marked.withOverrideAffected(true), sameInstance(marked));
161+
assertThat(marked.withOverrideAffected(false), equalTo(r));
162+
163+
// Every typed view carries the marked reason.
164+
assertThat(marked.getAsBoolean().getReason().isOverrideAffected(), is(true));
165+
assertThat(marked.getAsInteger().getReason().isOverrideAffected(), is(true));
166+
assertThat(marked.getAsDouble().getReason().isOverrideAffected(), is(true));
167+
assertThat(marked.getAsString().getReason().isOverrideAffected(), is(true));
168+
assertThat(marked.getAnyType().getReason().isOverrideAffected(), is(true));
169+
170+
// The original is untouched.
171+
assertThat(r.isOverrideAffected(), is(false));
172+
}
173+
174+
@Test
175+
public void withOverrideAffectedKeepsPrerequisiteRecordsAndForceTracking() {
176+
EvalResult r = EvalResult.of(SOME_VALUE, SOME_VARIATION, EvaluationReason.fallthrough(true))
177+
.withPrerequisiteEvalRecords(java.util.Collections.singletonList(
178+
new PrerequisiteEvalRecord(null, null, EvalResult.of(SOME_VALUE, SOME_VARIATION, SOME_REASON))));
179+
EvalResult marked = r.withOverrideAffected(true);
180+
assertThat(marked.isForceReasonTracking(), is(true));
181+
assertThat(marked.getPrerequisiteEvalRecords(), sameInstance(r.getPrerequisiteEvalRecords()));
182+
}
183+
137184
private <T> void testForType(T value, LDValue ldValue, Function<EvalResult, T> getter) {
138185
assertThat(
139186
getter.apply(EvalResult.of(EvaluationDetail.fromValue(ldValue, SOME_VARIATION, SOME_REASON))),

0 commit comments

Comments
 (0)