From ca22bc055728590809cbc9c0f6bd2ebb7a069eec Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Fri, 25 Sep 2026 15:17:28 -0700 Subject: [PATCH 01/30] 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. --- lib/sdk/server/build.gradle | 2 +- .../launchdarkly/sdk/server/DataModel.java | 60 +++ .../launchdarkly/sdk/server/EvalResult.java | 19 + .../launchdarkly/sdk/server/Evaluator.java | 35 +- .../sdk/server/InputValidatingEvaluator.java | 6 +- .../sdk/server/DataModelTest.java | 89 +++++ .../sdk/server/EvalResultTest.java | 47 +++ .../server/EvaluatorOverrideMarkingTest.java | 346 ++++++++++++++++++ .../InputValidatingEvaluatorOverrideTest.java | 103 ++++++ 9 files changed, 702 insertions(+), 5 deletions(-) create mode 100644 lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/EvaluatorOverrideMarkingTest.java create mode 100644 lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/InputValidatingEvaluatorOverrideTest.java diff --git a/lib/sdk/server/build.gradle b/lib/sdk/server/build.gradle index 1dadb4ed..399c78df 100644 --- a/lib/sdk/server/build.gradle +++ b/lib/sdk/server/build.gradle @@ -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 diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/DataModel.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/DataModel.java index 1e919cfe..0390abd2 100644 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/DataModel.java +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/DataModel.java @@ -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. */ @@ -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); } @@ -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, @@ -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); } diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/EvalResult.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/EvalResult.java index 8611afbb..ac7a2b89 100644 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/EvalResult.java +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/EvalResult.java @@ -228,6 +228,14 @@ public EvaluationDetail 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 getPrerequisiteEvalRecords() { return prerequisiteEvalRecords; } /** @@ -251,6 +259,17 @@ public EvalResult withForceReasonTracking(boolean newValue) { public EvalResult withPrerequisiteEvalRecords(List 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) { diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/Evaluator.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/Evaluator.java index 845d4aaf..fefd1add 100644 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/Evaluator.java +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/Evaluator.java @@ -124,6 +124,12 @@ private static class EvaluatorState { private List prerequisiteStack = null; private List prerequisiteEvalRecords = new ArrayList<>(0); // 0 initial capacity uses a static instance for performance private List 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) { @@ -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); @@ -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); } } @@ -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()) { @@ -461,6 +484,12 @@ private boolean matchAnySegment(List 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; } 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 8e7b61fd..013a7b5d 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 @@ -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); } } diff --git a/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/DataModelTest.java b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/DataModelTest.java index 9cbbe9cd..eab1684c 100644 --- a/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/DataModelTest.java +++ b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/DataModelTest.java @@ -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 { @@ -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()); + } } diff --git a/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/EvalResultTest.java b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/EvalResultTest.java index 71ef5dec..59f1dfd0 100644 --- a/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/EvalResultTest.java +++ b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/EvalResultTest.java @@ -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") @@ -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 void testForType(T value, LDValue ldValue, Function getter) { assertThat( getter.apply(EvalResult.of(EvaluationDetail.fromValue(ldValue, SOME_VARIATION, SOME_REASON))), diff --git a/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/EvaluatorOverrideMarkingTest.java b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/EvaluatorOverrideMarkingTest.java new file mode 100644 index 00000000..98affee2 --- /dev/null +++ b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/EvaluatorOverrideMarkingTest.java @@ -0,0 +1,346 @@ +package com.launchdarkly.sdk.server; + +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.server.DataModel.FeatureFlag; +import com.launchdarkly.sdk.server.DataModel.Segment; +import com.launchdarkly.sdk.server.EvaluatorTestUtil.EvaluatorBuilder; + +import org.junit.Test; + +import java.util.ArrayList; +import java.util.List; + +import static com.launchdarkly.sdk.server.EvaluatorTestUtil.BASE_USER; +import static com.launchdarkly.sdk.server.EvaluatorTestUtil.FALLTHROUGH_VALUE; +import static com.launchdarkly.sdk.server.EvaluatorTestUtil.FALLTHROUGH_VARIATION; +import static com.launchdarkly.sdk.server.EvaluatorTestUtil.GREEN_VARIATION; +import static com.launchdarkly.sdk.server.EvaluatorTestUtil.MATCH_VALUE; +import static com.launchdarkly.sdk.server.EvaluatorTestUtil.MATCH_VARIATION; +import static com.launchdarkly.sdk.server.EvaluatorTestUtil.OFF_VALUE; +import static com.launchdarkly.sdk.server.EvaluatorTestUtil.OFF_VARIATION; +import static com.launchdarkly.sdk.server.EvaluatorTestUtil.buildRedGreenFlag; +import static com.launchdarkly.sdk.server.EvaluatorTestUtil.buildThreeWayFlag; +import static com.launchdarkly.sdk.server.EvaluatorTestUtil.evaluatorBuilder; +import static com.launchdarkly.sdk.server.ModelBuilders.clause; +import static com.launchdarkly.sdk.server.ModelBuilders.clauseMatchingSegment; +import static com.launchdarkly.sdk.server.ModelBuilders.flagBuilder; +import static com.launchdarkly.sdk.server.ModelBuilders.negateClause; +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.ModelBuilders.segmentRuleBuilder; +import static com.launchdarkly.sdk.server.ModelBuilders.target; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotSame; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; + +/** + * An evaluation is override-affected when any definition it read carries the override marker: the + * evaluated flag, a prerequisite at any depth, or a segment consulted during matching. The marking + * propagates upward only. A prerequisite's own record reflects only its own subtree. + */ +@SuppressWarnings("javadoc") +public class EvaluatorOverrideMarkingTest { + private static final LDContext OTHER_USER = LDContext.create("other"); + + private static final class RecordingRecorder implements EvaluationRecorder { + final List prerequisites = new ArrayList<>(); + + @Override + public void recordPrerequisiteEvaluation(FeatureFlag flag, FeatureFlag prereqOfFlag, LDContext context, EvalResult result) { + prerequisites.add(new PrerequisiteEvalRecord(flag, prereqOfFlag, result)); + } + } + + private static PrerequisiteEvalRecord recordFor(List records, String flagKey) { + for (PrerequisiteEvalRecord r : records) { + if (r.flag.getKey().equals(flagKey)) { + return r; + } + } + throw new AssertionError("no prerequisite record for " + flagKey); + } + + @Test + public void evaluationOfUnmarkedFlagIsNotOverrideAffected() { + FeatureFlag f = buildThreeWayFlag("feature").on(true).build(); + EvalResult result = evaluatorBuilder().build().evaluate(f, BASE_USER, new EvaluationRecorder() {}); + + assertFalse(result.isOverrideAffected()); + assertFalse(result.getReason().isOverrideAffected()); + assertEquals(EvaluationReason.fallthrough(), result.getReason()); + } + + @Test + public void evaluationOfOverrideFlagIsMarkedForEveryReasonKind() { + Evaluator e = evaluatorBuilder().build(); + + FeatureFlag off = buildThreeWayFlag("off").on(false).build().markedAsOverride(); + EvalResult offResult = e.evaluate(off, BASE_USER, new EvaluationRecorder() {}); + assertEquals(EvalResult.of(OFF_VALUE, OFF_VARIATION, EvaluationReason.off().withOverrideAffected(true)), offResult); + assertTrue(offResult.isOverrideAffected()); + + FeatureFlag fallthrough = buildThreeWayFlag("fallthrough").on(true).build().markedAsOverride(); + assertEquals(EvaluationReason.fallthrough().withOverrideAffected(true), + e.evaluate(fallthrough, BASE_USER, new EvaluationRecorder() {}).getReason()); + + FeatureFlag targeted = buildThreeWayFlag("target").on(true) + .targets(target(MATCH_VARIATION, BASE_USER.getKey())).build().markedAsOverride(); + assertEquals(EvaluationReason.targetMatch().withOverrideAffected(true), + e.evaluate(targeted, BASE_USER, new EvaluationRecorder() {}).getReason()); + + FeatureFlag ruled = buildThreeWayFlag("rule").on(true) + .rules(ruleBuilder().id("r").variation(MATCH_VARIATION) + .clauses(clause("key", DataModel.Operator.in, LDValue.of(BASE_USER.getKey()))).build()) + .build().markedAsOverride(); + EvalResult ruleResult = e.evaluate(ruled, BASE_USER, new EvaluationRecorder() {}); + assertEquals(EvaluationReason.ruleMatch(0, "r").withOverrideAffected(true), ruleResult.getReason()); + assertEquals(MATCH_VALUE, ruleResult.getValue()); + } + + @Test + public void evaluationErrorOfOverrideFlagIsMarked() { + // The fallthrough points at a variation that does not exist, which is a malformed flag. + FeatureFlag malformed = flagBuilder("malformed").on(true).variations(LDValue.of("only")) + .fallthroughVariation(5).offVariation(0).build().markedAsOverride(); + EvalResult result = evaluatorBuilder().build().evaluate(malformed, BASE_USER, new EvaluationRecorder() {}); + + assertEquals(EvaluationReason.error(ErrorKind.MALFORMED_FLAG).withOverrideAffected(true), result.getReason()); + assertTrue(result.isNoVariation()); + assertTrue(result.isOverrideAffected()); + } + + @Test + public void thrownEvaluationErrorOfOverrideFlagIsMarked() { + // A clause without an attribute makes the evaluator throw, which becomes an error result. + FeatureFlag broken = buildThreeWayFlag("broken").on(true) + .rules(ruleBuilder().id("r").variation(MATCH_VARIATION) + .clauses(clause(null, DataModel.Operator.in, LDValue.of("x"))).build()) + .build().markedAsOverride(); + EvalResult result = evaluatorBuilder().build().evaluate(broken, BASE_USER, new EvaluationRecorder() {}); + + assertEquals(EvaluationReason.error(ErrorKind.MALFORMED_FLAG).withOverrideAffected(true), result.getReason()); + } + + @Test + public void overridePrerequisiteMarksParentAndItsOwnRecord() { + FeatureFlag parent = buildThreeWayFlag("parent").on(true) + .prerequisites(prerequisite("prereq", GREEN_VARIATION)).build(); + FeatureFlag prereq = buildRedGreenFlag("prereq").on(true).build().markedAsOverride(); + RecordingRecorder recorder = new RecordingRecorder(); + + EvalResult result = evaluatorBuilder().withStoredFlags(prereq).build().evaluate(parent, BASE_USER, recorder); + + assertEquals(FALLTHROUGH_VALUE, result.getValue()); + assertEquals(EvaluationReason.fallthrough().withOverrideAffected(true), result.getReason()); + assertTrue(result.isOverrideAffected()); + + PrerequisiteEvalRecord record = recordFor(recorder.prerequisites, "prereq"); + assertTrue(record.result.isOverrideAffected()); + assertEquals(EvaluationReason.fallthrough().withOverrideAffected(true), record.result.getReason()); + // The same record is on the result. + assertTrue(recordFor(result.getPrerequisiteEvalRecords(), "prereq").result.isOverrideAffected()); + } + + @Test + public void failedOverridePrerequisiteStillMarksParent() { + FeatureFlag parent = buildThreeWayFlag("parent").on(true) + .prerequisites(prerequisite("prereq", GREEN_VARIATION)).build(); + FeatureFlag prereq = buildRedGreenFlag("prereq").on(false).build().markedAsOverride(); + + EvalResult result = evaluatorBuilder().withStoredFlags(prereq).build() + .evaluate(parent, BASE_USER, new EvaluationRecorder() {}); + + assertEquals(OFF_VALUE, result.getValue()); + assertEquals(EvaluationReason.prerequisiteFailed("prereq").withOverrideAffected(true), result.getReason()); + } + + @Test + public void unaffectedPrerequisiteRecordIsNotMarkedInsideMarkedEvaluation() { + FeatureFlag parent = buildThreeWayFlag("parent").on(true) + .prerequisites(prerequisite("overridden", GREEN_VARIATION), prerequisite("plain", GREEN_VARIATION)).build(); + FeatureFlag overridden = buildRedGreenFlag("overridden").on(true).build().markedAsOverride(); + FeatureFlag plain = buildRedGreenFlag("plain").on(true).build(); + RecordingRecorder recorder = new RecordingRecorder(); + + EvalResult result = evaluatorBuilder().withStoredFlags(overridden, plain).build().evaluate(parent, BASE_USER, recorder); + + assertTrue(result.isOverrideAffected()); + assertTrue(recordFor(recorder.prerequisites, "overridden").result.isOverrideAffected()); + assertFalse(recordFor(recorder.prerequisites, "plain").result.isOverrideAffected()); + assertEquals(EvaluationReason.fallthrough(), recordFor(recorder.prerequisites, "plain").result.getReason()); + } + + @Test + public void markingPropagatesUpwardThroughEveryDepthButNotSideways() { + // A depends on B and C. B depends on D, which is the only override. A, B, and D are marked. + // C is not. + FeatureFlag a = buildThreeWayFlag("a").on(true) + .prerequisites(prerequisite("b", GREEN_VARIATION), prerequisite("c", GREEN_VARIATION)).build(); + FeatureFlag b = buildRedGreenFlag("b").on(true).prerequisites(prerequisite("d", GREEN_VARIATION)).build(); + FeatureFlag c = buildRedGreenFlag("c").on(true).build(); + FeatureFlag d = buildRedGreenFlag("d").on(true).build().markedAsOverride(); + RecordingRecorder recorder = new RecordingRecorder(); + + EvalResult result = evaluatorBuilder().withStoredFlags(b, c, d).build().evaluate(a, BASE_USER, recorder); + + assertTrue(result.isOverrideAffected()); + assertTrue(recordFor(recorder.prerequisites, "b").result.isOverrideAffected()); + assertTrue(recordFor(recorder.prerequisites, "d").result.isOverrideAffected()); + assertFalse(recordFor(recorder.prerequisites, "c").result.isOverrideAffected()); + } + + @Test + public void overrideFlagWithUnmarkedPrerequisiteMarksOnlyItself() { + FeatureFlag parent = buildThreeWayFlag("parent").on(true) + .prerequisites(prerequisite("prereq", GREEN_VARIATION)).build().markedAsOverride(); + FeatureFlag prereq = buildRedGreenFlag("prereq").on(true).build(); + RecordingRecorder recorder = new RecordingRecorder(); + + EvalResult result = evaluatorBuilder().withStoredFlags(prereq).build().evaluate(parent, BASE_USER, recorder); + + assertTrue(result.isOverrideAffected()); + // The parent's marking does not flow down into the prerequisite's record. + assertFalse(recordFor(recorder.prerequisites, "prereq").result.isOverrideAffected()); + } + + @Test + public void circularReferenceThroughOverridePrerequisiteIsMarkedError() { + FeatureFlag a = buildThreeWayFlag("a").on(true).prerequisites(prerequisite("b", GREEN_VARIATION)).build(); + FeatureFlag b = buildRedGreenFlag("b").on(true).prerequisites(prerequisite("a", GREEN_VARIATION)).build() + .markedAsOverride(); + + EvalResult result = evaluatorBuilder().withStoredFlags(b).build().evaluate(a, BASE_USER, new EvaluationRecorder() {}); + + assertEquals(EvaluationReason.error(ErrorKind.MALFORMED_FLAG).withOverrideAffected(true), result.getReason()); + } + + @Test + public void matchingOverrideSegmentMarksEvaluation() { + Segment segment = segmentBuilder("seg").included(BASE_USER.getKey()).build().markedAsOverride(); + FeatureFlag f = buildThreeWayFlag("feature").on(true) + .rules(ruleBuilder().id("r").variation(MATCH_VARIATION).clauses(clauseMatchingSegment("seg")).build()) + .build(); + + EvalResult result = evaluatorBuilder().withStoredSegments(segment).build() + .evaluate(f, BASE_USER, new EvaluationRecorder() {}); + + assertEquals(MATCH_VALUE, result.getValue()); + assertEquals(EvaluationReason.ruleMatch(0, "r").withOverrideAffected(true), result.getReason()); + } + + @Test + public void nonMatchingOverrideSegmentStillMarksEvaluation() { + Segment segment = segmentBuilder("seg").included(BASE_USER.getKey()).build().markedAsOverride(); + FeatureFlag f = buildThreeWayFlag("feature").on(true) + .rules(ruleBuilder().id("r").variation(MATCH_VARIATION).clauses(clauseMatchingSegment("seg")).build()) + .build(); + + EvalResult result = evaluatorBuilder().withStoredSegments(segment).build() + .evaluate(f, OTHER_USER, new EvaluationRecorder() {}); + + assertEquals(FALLTHROUGH_VALUE, result.getValue()); + assertEquals(EvaluationReason.fallthrough().withOverrideAffected(true), result.getReason()); + } + + @Test + public void negatedClauseOnOverrideSegmentMarksEvaluation() { + Segment segment = segmentBuilder("seg").included(BASE_USER.getKey()).build().markedAsOverride(); + FeatureFlag f = buildThreeWayFlag("feature").on(true) + .rules(ruleBuilder().id("r").variation(MATCH_VARIATION) + .clauses(negateClause(clauseMatchingSegment("seg"))).build()) + .build(); + + EvalResult result = evaluatorBuilder().withStoredSegments(segment).build() + .evaluate(f, OTHER_USER, new EvaluationRecorder() {}); + + assertEquals(MATCH_VALUE, result.getValue()); + assertTrue(result.isOverrideAffected()); + } + + @Test + public void unmarkedSegmentDoesNotMarkEvaluation() { + Segment segment = segmentBuilder("seg").included(BASE_USER.getKey()).build(); + FeatureFlag f = buildThreeWayFlag("feature").on(true) + .rules(ruleBuilder().id("r").variation(MATCH_VARIATION).clauses(clauseMatchingSegment("seg")).build()) + .build(); + + EvalResult result = evaluatorBuilder().withStoredSegments(segment).build() + .evaluate(f, BASE_USER, new EvaluationRecorder() {}); + + assertEquals(MATCH_VALUE, result.getValue()); + assertFalse(result.isOverrideAffected()); + } + + @Test + public void overrideSegmentReferencedByAnotherSegmentMarksEvaluation() { + Segment inner = segmentBuilder("inner").included(BASE_USER.getKey()).build().markedAsOverride(); + Segment outer = segmentBuilder("outer") + .rules(segmentRuleBuilder().clauses(clauseMatchingSegment("inner")).build()).build(); + FeatureFlag f = buildThreeWayFlag("feature").on(true) + .rules(ruleBuilder().id("r").variation(MATCH_VARIATION).clauses(clauseMatchingSegment("outer")).build()) + .build(); + + EvalResult result = evaluatorBuilder().withStoredSegments(inner, outer).build() + .evaluate(f, BASE_USER, new EvaluationRecorder() {}); + + assertEquals(MATCH_VALUE, result.getValue()); + assertTrue(result.isOverrideAffected()); + } + + @Test + public void segmentReadByPrerequisiteMarksPrerequisiteAndParent() { + Segment segment = segmentBuilder("seg").included(BASE_USER.getKey()).build().markedAsOverride(); + FeatureFlag parent = buildThreeWayFlag("parent").on(true) + .prerequisites(prerequisite("prereq", GREEN_VARIATION)).build(); + FeatureFlag prereq = buildRedGreenFlag("prereq").on(true) + .rules(ruleBuilder().id("r").variation(GREEN_VARIATION).clauses(clauseMatchingSegment("seg")).build()) + .build(); + RecordingRecorder recorder = new RecordingRecorder(); + + EvalResult result = evaluatorBuilder().withStoredFlags(prereq).withStoredSegments(segment).build() + .evaluate(parent, BASE_USER, recorder); + + assertTrue(result.isOverrideAffected()); + assertTrue(recordFor(recorder.prerequisites, "prereq").result.isOverrideAffected()); + } + + @Test + public void markedEvaluationDoesNotAlterSharedPrecomputedResults() { + FeatureFlag source = buildThreeWayFlag("feature").on(false).build(); + FeatureFlag marked = source.markedAsOverride(); + Evaluator e = evaluatorBuilder().build(); + + EvalResult markedResult = e.evaluate(marked, BASE_USER, new EvaluationRecorder() {}); + EvalResult sourceResult = e.evaluate(source, BASE_USER, new EvaluationRecorder() {}); + + assertTrue(markedResult.isOverrideAffected()); + assertFalse(sourceResult.isOverrideAffected()); + assertFalse(source.isOverride()); + assertNotSame(markedResult, sourceResult); + // The unmarked evaluation still returns the shared precomputed instance. + assertSame(sourceResult, e.evaluate(source, BASE_USER, new EvaluationRecorder() {})); + } + + @Test + public void bigSegmentsStatusAndOverrideMarkingAreBothKept() { + Segment bigSegment = segmentBuilder("big").unbounded(true).generation(1).build().markedAsOverride(); + FeatureFlag f = buildThreeWayFlag("feature").on(true) + .rules(ruleBuilder().id("r").variation(MATCH_VARIATION).clauses(clauseMatchingSegment("big")).build()) + .build(); + // No big segment store is configured, so the status is NOT_CONFIGURED. + EvaluatorBuilder builder = evaluatorBuilder().withStoredSegments(bigSegment) + .withBigSegmentQueryResult(BASE_USER.getKey(), null); + + EvalResult result = builder.build().evaluate(f, BASE_USER, new EvaluationRecorder() {}); + + assertEquals(FALLTHROUGH_VARIATION, result.getVariationIndex()); + assertEquals(EvaluationReason.BigSegmentsStatus.NOT_CONFIGURED, result.getReason().getBigSegmentsStatus()); + assertTrue(result.getReason().isOverrideAffected()); + } +} 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 new file mode 100644 index 00000000..cf92d61c --- /dev/null +++ b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/InputValidatingEvaluatorOverrideTest.java @@ -0,0 +1,103 @@ +package com.launchdarkly.sdk.server; + +import com.launchdarkly.sdk.EvaluationReason; +import com.launchdarkly.sdk.EvaluationReason.ErrorKind; +import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.LDValueType; +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.AbstractMap; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +import static com.launchdarkly.sdk.server.DataModel.FEATURES; +import static com.launchdarkly.sdk.server.EvaluatorTestUtil.BASE_USER; +import static com.launchdarkly.sdk.server.ModelBuilders.flagBuilder; +import static com.launchdarkly.sdk.server.TestComponents.nullLogger; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +@SuppressWarnings("javadoc") +public class InputValidatingEvaluatorOverrideTest { + private static ReadOnlyStore storeWith(FeatureFlag... flags) { + Map items = new HashMap<>(); + for (FeatureFlag f : flags) { + items.put(f.getKey(), new ItemDescriptor(f.getVersion(), f)); + } + return new ReadOnlyStore() { + @Override + public ItemDescriptor get(DataKind kind, String key) { + return kind == FEATURES ? items.get(key) : null; + } + + @Override + public KeyedItems getAll(DataKind kind) { + return kind == FEATURES ? new KeyedItems<>(items.entrySet()) : new KeyedItems<>(Collections.emptyList()); + } + + @Override + public boolean isInitialized() { + return true; + } + }; + } + + private static InputValidatingEvaluator evaluatorOver(ReadOnlyStore store) { + return new InputValidatingEvaluator(store, null, new NoOpEventProcessor(), nullLogger); + } + + @Test + public void typeMismatchOnOverrideFlagKeepsMarking() { + FeatureFlag flag = flagBuilder("flag").on(false).offVariation(0).variations(LDValue.of("a string")) + .build().markedAsOverride(); + EvalResultAndFlag result = evaluatorOver(storeWith(flag)).evaluate("flag", BASE_USER, LDValue.of(true), + LDValueType.BOOLEAN, InputValidatingEvaluator.NO_OP_EVALUATION_EVENT_RECORDER); + + assertEquals(LDValue.of(true), result.getResult().getValue()); + assertEquals(EvaluationReason.error(ErrorKind.WRONG_TYPE).withOverrideAffected(true), result.getResult().getReason()); + assertTrue(result.getResult().isOverrideAffected()); + } + + @Test + public void typeMismatchOnOrdinaryFlagIsNotMarked() { + FeatureFlag flag = flagBuilder("flag").on(false).offVariation(0).variations(LDValue.of("a string")).build(); + EvalResultAndFlag result = evaluatorOver(storeWith(flag)).evaluate("flag", BASE_USER, LDValue.of(true), + LDValueType.BOOLEAN, InputValidatingEvaluator.NO_OP_EVALUATION_EVENT_RECORDER); + + assertEquals(EvaluationReason.error(ErrorKind.WRONG_TYPE), result.getResult().getReason()); + assertFalse(result.getResult().isOverrideAffected()); + } + + @Test + public void errorResultOfOverrideFlagKeepsMarkingWithCallerDefault() { + FeatureFlag malformed = flagBuilder("flag").on(true).variations(LDValue.of("only")) + .fallthroughVariation(5).offVariation(0).build().markedAsOverride(); + EvalResultAndFlag result = evaluatorOver(storeWith(malformed)).evaluate("flag", BASE_USER, LDValue.of("fallback"), + null, InputValidatingEvaluator.NO_OP_EVALUATION_EVENT_RECORDER); + + assertEquals(LDValue.of("fallback"), result.getResult().getValue()); + assertTrue(result.getResult().isNoVariation()); + assertEquals(EvaluationReason.error(ErrorKind.MALFORMED_FLAG).withOverrideAffected(true), result.getResult().getReason()); + } + + @Test + public void allFlagsStateKeepsMarkedReason() { + FeatureFlag marked = flagBuilder("marked").on(false).offVariation(0).variations(LDValue.of("x")).build() + .markedAsOverride(); + FeatureFlag plain = flagBuilder("plain").on(false).offVariation(0).variations(LDValue.of("y")).build(); + FeatureFlagsState state = evaluatorOver(storeWith(marked, plain)).allFlagsState(BASE_USER, FlagsStateOption.WITH_REASONS); + + assertTrue(state.isValid()); + assertTrue(state.getFlagReason("marked").isOverrideAffected()); + assertFalse(state.getFlagReason("plain").isOverrideAffected()); + Map.Entry expected = new AbstractMap.SimpleEntry<>("marked", LDValue.of("x")); + assertEquals(expected.getValue(), state.getFlagValue("marked")); + } +} From 8fbcf92cebf23ffff6848bcb9793b694db44538c Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Wed, 28 Oct 2026 22:12:11 +0000 Subject: [PATCH 02/30] fix: Keep the override marking when a migration flag serves an unrecognized stage migrationVariation replaces the result of a flag that serves an unrecognized stage with a WRONG_TYPE error. The evaluation read the same definitions, so the replacement keeps the override-affected marking, as the type mismatch of an ordinary variation call already does, and the migration tracker receives a marked reason for an overridden flag. --- .../server/MigrationStageEnforcingEvaluator.java | 5 ++++- .../InputValidatingEvaluatorOverrideTest.java | 13 +++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/MigrationStageEnforcingEvaluator.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/MigrationStageEnforcingEvaluator.java index bdf2be3f..806a8bb2 100644 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/MigrationStageEnforcingEvaluator.java +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/MigrationStageEnforcingEvaluator.java @@ -28,7 +28,10 @@ public EvalResultAndFlag evalAndFlag(String method, String flagKey, LDContext co String resStageString = resDetail.getValue(); if (!MigrationStage.isStage(resStageString)) { logger.error("Unrecognized MigrationState for \"{}\"; returning default value.", flagKey); - return new EvalResultAndFlag(EvalResult.error(EvaluationReason.ErrorKind.WRONG_TYPE, defaultValue), res.getFlag()); + // The unrecognized stage replaces the result. The evaluation read the same definitions, so + // the new result keeps the override-affected marking. + return new EvalResultAndFlag(EvalResult.error(EvaluationReason.ErrorKind.WRONG_TYPE, defaultValue) + .withOverrideAffected(res.getResult().isOverrideAffected()), res.getFlag()); } return res; 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..aea906f8 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 @@ -65,6 +65,19 @@ public void typeMismatchOnOverrideFlagKeepsMarking() { assertTrue(result.getResult().isOverrideAffected()); } + @Test + public void unrecognizedMigrationStageFromOverrideFlagKeepsMarking() { + FeatureFlag flag = flagBuilder("flag").on(false).offVariation(0).variations(LDValue.of("potato")) + .build().markedAsOverride(); + MigrationStageEnforcingEvaluator evaluator = + new MigrationStageEnforcingEvaluator(evaluatorOver(storeWith(flag)), nullLogger); + EvalResultAndFlag result = evaluator.evalAndFlag("LDClient.migrationVariation", "flag", BASE_USER, + LDValue.of("off"), LDValueType.STRING, EvaluationOptions.EVENTS_WITHOUT_REASONS); + + assertEquals(LDValue.of("off"), result.getResult().getValue()); + assertEquals(EvaluationReason.error(ErrorKind.WRONG_TYPE).withOverrideAffected(true), result.getResult().getReason()); + } + @Test public void typeMismatchOnOrdinaryFlagIsNotMarked() { FeatureFlag flag = flagBuilder("flag").on(false).offVariation(0).variations(LDValue.of("a string")).build(); From 64740d7c462e6f5318e0326eb6a6f564733a9578 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Thu, 29 Oct 2026 16:54:13 +0000 Subject: [PATCH 03/30] fix: Mark the EXCEPTION result of an overridden flag When the evaluation of a flag throws, the error result now carries the override-affected marking of the flag's own definition, which was read before the failure. The spec marks a failed evaluation when a definition it read came from the override store. --- .../sdk/server/InputValidatingEvaluator.java | 7 ++- .../InputValidatingEvaluatorOverrideTest.java | 54 +++++++++++++++++++ 2 files changed, 60 insertions(+), 1 deletion(-) 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..85773488 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 @@ -155,7 +155,12 @@ EvalResultAndFlag evaluate(String flagKey, LDContext context, LDValue defaultVal } else { recorder.recordEvaluationError(featureFlag, context, defaultValue, ErrorKind.EXCEPTION); } - return new EvalResultAndFlag(EvalResult.of(defaultValue, NO_VARIATION, EvaluationReason.exception(e)), null); + // The flag's definition was read before the failure, so its own marker decides the marking of + // the error result. + boolean overrideAffected = featureFlag != null && featureFlag.isOverride(); + return new EvalResultAndFlag( + EvalResult.of(defaultValue, NO_VARIATION, EvaluationReason.exception(e)).withOverrideAffected(overrideAffected), + null); } } 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 aea906f8..1b00f953 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 @@ -19,6 +19,7 @@ import static com.launchdarkly.sdk.server.DataModel.FEATURES; import static com.launchdarkly.sdk.server.EvaluatorTestUtil.BASE_USER; import static com.launchdarkly.sdk.server.ModelBuilders.flagBuilder; +import static com.launchdarkly.sdk.server.ModelBuilders.prerequisite; import static com.launchdarkly.sdk.server.TestComponents.nullLogger; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; @@ -49,6 +50,59 @@ public boolean isInitialized() { }; } + // A store that holds the given flags and fails when the given key is read, as a store does + // when its database is unreachable. + private static ReadOnlyStore storeFailingOn(String failingKey, FeatureFlag... flags) { + ReadOnlyStore base = storeWith(flags); + return new ReadOnlyStore() { + @Override + public ItemDescriptor get(DataKind kind, String key) { + if (key.equals(failingKey)) { + throw new RuntimeException("store failed"); + } + return base.get(kind, key); + } + + @Override + public KeyedItems getAll(DataKind kind) { + return base.getAll(kind); + } + + @Override + public boolean isInitialized() { + return true; + } + }; + } + + // A flag whose evaluation reads the prerequisite "prereq". + private static FeatureFlag flagWithPrerequisite(String key) { + return flagBuilder(key).on(true).prerequisites(prerequisite("prereq", 0)).fallthroughVariation(0) + .offVariation(0).variations(LDValue.of("value")).trackEvents(true).build(); + } + + @Test + public void exceptionWhileEvaluatingOverrideFlagKeepsMarking() { + FeatureFlag flag = flagWithPrerequisite("flag").markedAsOverride(); + EvalResultAndFlag result = evaluatorOver(storeFailingOn("prereq", flag)).evaluate("flag", BASE_USER, + LDValue.of("default"), null, InputValidatingEvaluator.NO_OP_EVALUATION_EVENT_RECORDER); + + assertEquals(LDValue.of("default"), result.getResult().getValue()); + assertEquals(EvaluationReason.ErrorKind.EXCEPTION, result.getResult().getReason().getErrorKind()); + assertTrue(result.getResult().getReason().isOverrideAffected()); + assertTrue(result.getResult().isOverrideAffected()); + } + + @Test + public void exceptionWhileEvaluatingPlainFlagIsNotMarked() { + FeatureFlag flag = flagWithPrerequisite("flag"); + EvalResultAndFlag result = evaluatorOver(storeFailingOn("prereq", flag)).evaluate("flag", BASE_USER, + LDValue.of("default"), null, InputValidatingEvaluator.NO_OP_EVALUATION_EVENT_RECORDER); + + assertEquals(EvaluationReason.ErrorKind.EXCEPTION, result.getResult().getReason().getErrorKind()); + assertFalse(result.getResult().isOverrideAffected()); + } + private static InputValidatingEvaluator evaluatorOver(ReadOnlyStore store) { return new InputValidatingEvaluator(store, null, new NoOpEventProcessor(), nullLogger); } From 222bea82b1bc295f54cc40567a76ed3709513481 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 04/30] 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 85773488..7c78e5a8 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); @@ -168,8 +178,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) { @@ -219,6 +237,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 1b00f953..e86a65db 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 @@ -104,7 +104,7 @@ public void exceptionWhileEvaluatingPlainFlagIsNotMarked() { } 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 + } + } + ] +} From b955b3640b9d14294210d3c751fa492959eea588 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Thu, 1 Oct 2026 23:25:40 +0000 Subject: [PATCH 05/30] test: Model value-only overrides as flags served by fallthrough --- .../launchdarkly/sdk/server/LDClientOverridesTest.java | 6 +++--- .../launchdarkly/sdk/server/OverrideSpecVectorsTest.java | 8 +++++--- .../src/test/resources/override-vectors/vectors.json | 4 ++-- 3 files changed, 10 insertions(+), 8 deletions(-) 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 index 9e039c18..a23b8c9c 100644 --- 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 @@ -57,7 +57,7 @@ public class LDClientOverridesTest extends BaseTest { 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(); + return flagBuilder(key).version(1).on(true).fallthroughVariation(0).variations(LDValue.of(value)).build(); } private LDConfig.Builder configWith(DataSystemBuilder dataSystem) { @@ -81,7 +81,7 @@ private LDConfig uninitializedConfig(ComponentConfigurer source) 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()); + assertEquals(EvaluationReason.fallthrough().withOverrideAffected(true), detail.getReason()); } private static void assertNotReady(EvaluationDetail detail) { @@ -130,7 +130,7 @@ public void overrideTakesPrecedenceOverLaunchDarklyDataAndOtherFlagsAreUnaffecte 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()); + assertEquals(EvaluationReason.fallthrough(), plain.getReason()); assertFalse(plain.getReason().isOverrideAffected()); // Removing the override returns the flag to LaunchDarkly data. 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 index 467a362e..d211da32 100644 --- 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 @@ -59,9 +59,11 @@ static Iterable>> collectionsFrom 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(); + // A value-only entry is a flag that is on and serves the value as its single variation by + // fallthrough. + LDValue flag = LDValue.buildObject().put("key", key).put("version", 0).put("on", true) + .put("variations", LDValue.buildArray().add(flagValues.get(key)).build()) + .put("fallthrough", LDValue.buildObject().put("variation", 0).build()).build(); flagItems.add(new AbstractMap.SimpleEntry<>(key, FEATURES.deserialize(flag.toJsonString()))); } List> segmentItems = new ArrayList<>(); diff --git a/lib/sdk/server/src/test/resources/override-vectors/vectors.json b/lib/sdk/server/src/test/resources/override-vectors/vectors.json index 48c3347c..82dc62da 100644 --- a/lib/sdk/server/src/test/resources/override-vectors/vectors.json +++ b/lib/sdk/server/src/test/resources/override-vectors/vectors.json @@ -30,7 +30,7 @@ "expect": { "value": "override-value", "variationIndex": 0, - "reason": { "kind": "OFF", "overrideAffected": true }, + "reason": { "kind": "FALLTHROUGH", "overrideAffected": true }, "summaryOverrideAffected": true } }, @@ -53,7 +53,7 @@ "expect": { "value": "override-value", "variationIndex": 0, - "reason": { "kind": "OFF", "overrideAffected": true }, + "reason": { "kind": "FALLTHROUGH", "overrideAffected": true }, "summaryOverrideAffected": true } }, From 9fcdd5fe3a018be8966b6189290d9964e423837e Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Sat, 3 Oct 2026 00:29:03 +0000 Subject: [PATCH 06/30] docs: Mark the constructor that takes an override source as experimental --- .../sdk/server/subsystems/DataSystemConfiguration.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 692d7998..50ecdd2e 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 @@ -62,7 +62,8 @@ public DataSystemConfiguration( } /** - * Creates an instance. + * Creates an instance with an optional override source. Flag overrides are currently experimental + * and subject to change. *

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

From 825be9d82c245ca36d1b0a7ee907d89a8d79c8cf Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Sat, 3 Oct 2026 01:39:31 +0000 Subject: [PATCH 07/30] fix: Log a summary of the exception when the override source fails to close --- .../main/java/com/launchdarkly/sdk/server/FDv2DataSystem.java | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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 500e1ea2..94467183 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 @@ -2,6 +2,7 @@ import com.google.common.collect.ImmutableList; import com.launchdarkly.logging.LDLogger; +import com.launchdarkly.logging.LogValues; import com.launchdarkly.sdk.server.datasources.Initializer; import com.launchdarkly.sdk.server.datasources.SelectorSource; import com.launchdarkly.sdk.server.datasources.Synchronizer; @@ -287,7 +288,8 @@ public void close() throws IOException { try { overrideSource.close(); } catch (IOException | RuntimeException e) { - logger.warn("Error closing override source: {}", e.toString()); + logger.warn("Error closing override source: {}", LogValues.exceptionSummary(e)); + logger.debug(LogValues.exceptionTrace(e)); } } dataSource.close(); From b177a7bf1aa42841ed7cbeabf29d18f837dbcf93 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Fri, 16 Oct 2026 21:17:29 +0000 Subject: [PATCH 08/30] fix: Notify overridden flags when the LaunchDarkly data they depend on changes, and let isFlagKnown see overrides A flag override can reference LaunchDarkly data: a rule that matches a LaunchDarkly segment, or a prerequisite that is a LaunchDarkly flag. DataSourceUpdatesImpl computes flag change notifications from a dependency tracker built from LaunchDarkly data alone, so when such a segment or flag changed, the flags whose overrides depend on it were not notified. The override layer now keeps the dependency edges of its own entries, rebuilt on every replacement, and the data source updates ask it for the override entries that depend on a changed item. The chain can cross in both directions, so each newly reached item is expanded through both sets of edges until nothing new is found. isFlagKnown consults the override layer before the not-initialized short circuit, the same as evaluation does, so a caller that gates on it does not skip an override during an outage. --- .../sdk/server/DataModelDependencies.java | 8 +++ .../sdk/server/DataSourceUpdatesImpl.java | 54 +++++++++++++++++-- .../sdk/server/FDv2DataSystem.java | 7 ++- .../com/launchdarkly/sdk/server/LDClient.java | 11 ++++ .../sdk/server/OverrideLayer.java | 23 +++++++- .../sdk/server/DataSourceUpdatesImplTest.java | 30 +++++++++++ .../sdk/server/LDClientOverridesTest.java | 30 +++++++++++ .../sdk/server/OverrideLayerTest.java | 46 ++++++++++++++++ 8 files changed, 203 insertions(+), 6 deletions(-) diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/DataModelDependencies.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/DataModelDependencies.java index 17ca660e..543755d5 100644 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/DataModelDependencies.java +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/DataModelDependencies.java @@ -218,6 +218,14 @@ public int compare(DataKind o1, DataKind o2) { /** * Maintains a bidirectional dependency graph that can be updated whenever an item has changed. */ + /** + * Dependency edges that live outside the LaunchDarkly data, such as those of the override + * layer's entries. Given an item, adds it and everything that depends on it through those edges. + */ + interface ExternalDependencies { + void addAffectedItems(Set itemsOut, KindAndKey item); + } + static final class DependencyTracker { private final Map> dependenciesFrom = new HashMap<>(); private final Map> dependenciesTo = new HashMap<>(); diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/DataSourceUpdatesImpl.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/DataSourceUpdatesImpl.java index 2f945f64..e9d7854f 100644 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/DataSourceUpdatesImpl.java +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/DataSourceUpdatesImpl.java @@ -29,6 +29,9 @@ import java.util.HashMap; import java.util.HashSet; import java.util.Map; +import java.util.Deque; +import java.util.Arrays; +import java.util.ArrayDeque; import java.util.Set; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.ScheduledFuture; @@ -56,6 +59,10 @@ final class DataSourceUpdatesImpl implements DataSourceUpdateSink, DataSourceUpd private final EventBroadcasterImpl flagChangeEventNotifier; private final EventBroadcasterImpl dataSourceStatusNotifier; private final DataModelDependencies.DependencyTracker dependencyTracker = new DataModelDependencies.DependencyTracker(); + // Dependencies of definitions the data system does not hold. The override layer supplies its + // entries' dependencies, so that a change to a LaunchDarkly item also notifies the overridden + // flags that reference it. Null when no override source is configured. + private volatile DataModelDependencies.ExternalDependencies externalDependencies = null; private final DataStoreStatusProvider dataStoreStatusProvider; private final OutageTracker outageTracker; private final Object stateLock = new Object(); @@ -84,6 +91,47 @@ final class DataSourceUpdatesImpl implements DataSourceUpdateSink, DataSourceUpd currentStatus = new Status(State.INITIALIZING, Instant.now(), null); } + /** + * Registers dependency edges that live outside the LaunchDarkly data, such as the override + * layer's. Called once during construction of the data system. + */ + void setExternalDependencies(DataModelDependencies.ExternalDependencies externalDependencies) { + this.externalDependencies = externalDependencies; + } + + /** + * Adds the modified item and everything that depends on it to the set. The LaunchDarkly data and + * the external dependencies each know their own edges, and a chain can cross between them in + * either direction: an overridden flag can reference a LaunchDarkly segment, and a LaunchDarkly + * flag can have an overridden prerequisite. Each newly reached item is therefore expanded through + * both until nothing new is found. + */ + private void addAffectedItems(Set itemsOut, KindAndKey item) { + DataModelDependencies.ExternalDependencies external = externalDependencies; + if (external == null) { + dependencyTracker.addAffectedItems(itemsOut, item); + return; + } + Deque pending = new ArrayDeque<>(); + pending.push(item); + while (!pending.isEmpty()) { + KindAndKey next = pending.pop(); + // Each tracker gets its own set: a tracker skips an item that is already in the set it is + // given, so sharing one set would stop the second tracker at the starting item. + Set viaData = new HashSet<>(); + dependencyTracker.addAffectedItems(viaData, next); + Set viaExternal = new HashSet<>(); + external.addAffectedItems(viaExternal, next); + for (Set reached : Arrays.asList(viaData, viaExternal)) { + for (KindAndKey reachedItem : reached) { + if (itemsOut.add(reachedItem)) { + pending.push(reachedItem); + } + } + } + } + } + @Override public boolean init(FullDataSet allData) { Map> oldData = null; @@ -132,7 +180,7 @@ public boolean upsert(DataKind kind, String key, ItemDescriptor item) { dependencyTracker.updateDependenciesFrom(kind, key, item); if (hasFlagChangeEventListeners()) { Set affectedItems = new HashSet<>(); - dependencyTracker.addAffectedItems(affectedItems, new KindAndKey(kind, key)); + addAffectedItems(affectedItems, new KindAndKey(kind, key)); sendChangeEvents(affectedItems); } } @@ -270,7 +318,7 @@ private Set computeChangedItemsForFullDataSet(Map handlePartialChangeset( for (Map.Entry itemEntry: kindEntry.getValue().getItems()) { String key = itemEntry.getKey(); dependencyTracker.updateDependenciesFrom(kind, key, itemEntry.getValue()); - dependencyTracker.addAffectedItems(affectedItems, new KindAndKey(kind, key)); + addAffectedItems(affectedItems, new KindAndKey(kind, key)); } } 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 94467183..67d46139 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 @@ -220,7 +220,7 @@ static FDv2DataSystem create( overrideSource = dataSystemConfiguration.getOverrideSource().build(clientContext); } - return new FDv2DataSystem( + FDv2DataSystem system = new FDv2DataSystem( store, dataSource, dataSourceStatusProvider, @@ -230,6 +230,11 @@ static FDv2DataSystem create( overrideSource, logger ); + if (system.overrideLayer != null) { + // A LaunchDarkly change must also notify the flags whose overrides depend on it. + dataSourceUpdates.setExternalDependencies(system.overrideLayer); + } + return system; } @Override 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 88186d76..e0c899d8 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 @@ -429,6 +429,17 @@ public MigrationVariation migrationVariation(String key, LDContext context, Migr @Override public boolean isFlagKnown(String featureKey) { + // An override makes a flag known whether or not the client is initialized, the same as + // evaluation serves an override before the not-initialized short circuit (spec 1.5.3). A + // caller that gates on isFlagKnown must not skip an override during an outage. + OverrideLayer overrideLayer = dataSystem.getOverrideLayer(); + if (overrideLayer != null) { + ItemDescriptor overridden = overrideLayer.get(DataModel.FEATURES, featureKey); + if (overridden != null && overridden.getItem() != null) { + return true; + } + } + ReadOnlyStore store = dataSystem.getStore(); if (!isInitialized()) { 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 index 02db4edd..ce1f55ea 100644 --- 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 @@ -9,6 +9,7 @@ import java.util.HashMap; import java.util.Map; +import java.util.Set; /** * The override layer: a thread-safe store of override entries that an override source replaces @@ -21,7 +22,7 @@ *

* This class is package-private and should not be used by application code. */ -final class OverrideLayer { +final class OverrideLayer implements DataModelDependencies.ExternalDependencies { /** * The previous and current contents of the layer after a replacement. The maps must not be * modified. @@ -45,6 +46,9 @@ static final class Replacement { // 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(); + // The dependency edges of the layer's own entries: which entries reference which flags and + // segments. Rebuilt with the contents on every replacement and swapped with them. + private volatile DataModelDependencies.DependencyTracker dependencies = new DataModelDependencies.DependencyTracker(); /** * Atomically replaces the entire layer contents. A null or empty collection clears the layer. @@ -54,6 +58,7 @@ static final class Replacement { */ Replacement setAll(Iterable>> data) { Map> collected = new HashMap<>(); + DataModelDependencies.DependencyTracker tracker = new DataModelDependencies.DependencyTracker(); int count = 0; if (data != null) { for (Map.Entry> kindEntry : data) { @@ -64,7 +69,9 @@ Replacement setAll(Iterable>> dat continue; } for (Map.Entry item : kindItems) { - items.put(item.getKey(), markedCopy(item.getValue())); + ItemDescriptor copied = markedCopy(item.getValue()); + items.put(item.getKey(), copied); + tracker.updateDependenciesFrom(kindEntry.getKey(), item.getKey(), copied); count++; } } @@ -78,11 +85,23 @@ Replacement setAll(Iterable>> dat synchronized (writeLock) { ImmutableMap> previous = contents; contents = replacement; + dependencies = tracker; nonEmpty = count != 0; return new Replacement(previous, replacement); } } + /** + * Adds the given item to the set together with every item whose override entry depends on it, + * directly or through other override entries. A LaunchDarkly update uses this so that a change to + * a LaunchDarkly segment or prerequisite notifies the overridden flags that reference it, which + * the data system's own dependency tracking cannot see. + */ + @Override + public void addAffectedItems(Set itemsOut, DataModelDependencies.KindAndKey item) { + dependencies.addAffectedItems(itemsOut, item); + } + /** * Returns the override entry for a key, or null if the layer has none. * diff --git a/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/DataSourceUpdatesImplTest.java b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/DataSourceUpdatesImplTest.java index fee4df7d..59679f60 100644 --- a/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/DataSourceUpdatesImplTest.java +++ b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/DataSourceUpdatesImplTest.java @@ -41,6 +41,7 @@ import static com.launchdarkly.sdk.server.DataModel.FEATURES; import static com.launchdarkly.sdk.server.DataModel.SEGMENTS; +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; @@ -1053,4 +1054,33 @@ public void applyFullChangeSetToLegacyStoreWithEnvironmentId() throws Exception updates.apply(new ChangeSet<>(ChangeSetType.Full, Selector.make(2, "state2"), data, null, true)); assertNull(legacyStore.getEnvironmentId()); } + + @Test + public void sendsEventsToOverridesThatDependOnAnUpdatedItem() throws Exception { + // The override "overridden" matches the LaunchDarkly segment "segment1". The LaunchDarkly flag + // "parent" has "overridden" as a prerequisite, so a change to the segment reaches parent through + // the override entry: LaunchDarkly data -> override entry -> LaunchDarkly data. + DataStore store = inMemoryDataStore(); + DataSourceUpdatesImpl storeUpdates = makeInstance(store); + OverrideLayer layer = new OverrideLayer(); + layer.setAll(OverrideLayerTest.flagsOnly(flagBuilder("overridden").version(1).on(true) + .rules(ruleBuilder().id("r").variation(0).clauses(clauseMatchingSegment("segment1")).build()).build())); + storeUpdates.setExternalDependencies(layer); + DataBuilder builder = new DataBuilder() + .addAny(FEATURES, + flagBuilder("parent").version(1).prerequisites(prerequisite("overridden", 0)).build()) + .addAny(SEGMENTS, + segmentBuilder("segment1").version(1).build()); + storeUpdates.init(builder.build()); + + BlockingQueue eventSink = new LinkedBlockingQueue<>(); + flagChangeBroadcaster.register(eventSink::add); + + storeUpdates.upsert(SEGMENTS, "segment1", new ItemDescriptor(2, segmentBuilder("segment1").version(2).build())); + expectEvents(eventSink, "overridden", "parent"); + + // An item nothing references notifies no flag. + storeUpdates.upsert(SEGMENTS, "other", new ItemDescriptor(1, segmentBuilder("other").version(1).build())); + assertNull(eventSink.poll(200, TimeUnit.MILLISECONDS)); + } } 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 index a23b8c9c..264096db 100644 --- 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 @@ -10,6 +10,7 @@ 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.integrations.TestDataV2; import com.launchdarkly.sdk.server.interfaces.FlagChangeEvent; import com.launchdarkly.sdk.server.subsystems.ComponentConfigurer; import com.launchdarkly.sdk.server.subsystems.DataStoreTypes.DataKind; @@ -380,4 +381,33 @@ private static Set awaitKeys(BlockingQueue events, int } return keys; } + + @Test + public void isFlagKnownReportsAnOverrideBeforeTheClientIsInitialized() throws Exception { + TestOverrideSource source = new TestOverrideSource(flagsOnly(valueFlag("overridden", "v"))); + try (LDClient client = new LDClient(SDK_KEY, uninitializedConfig(source))) { + assertFalse(client.isInitialized()); + assertTrue(client.isFlagKnown("overridden")); + assertFalse(client.isFlagKnown("other")); + } + } + + @Test + public void launchDarklyChangeNotifiesOverridesThatDependOnIt() throws Exception { + // The override "overridden" has the LaunchDarkly flag "prereq" as a prerequisite. A change to + // prereq on the LaunchDarkly side notifies the override too. + TestDataV2 td = TestDataV2.synchronizer(); + td.update(td.flag("prereq").on(true)); + FeatureFlag overridden = flagBuilder("overridden").version(1).on(true).fallthroughVariation(0).offVariation(0) + .variations(LDValue.of("x")).prerequisites(prerequisite("prereq", 0)).build(); + TestOverrideSource source = new TestOverrideSource(flagsOnly(overridden)); + LDConfig config = configWith(Components.dataSystem().custom().synchronizers(td).overrides(source)).build(); + try (LDClient client = new LDClient(SDK_KEY, config)) { + BlockingQueue events = new LinkedBlockingQueue<>(); + client.getFlagTracker().addFlagChangeListener(events::add); + td.update(td.flag("prereq").on(false)); + assertEquals(keys("prereq", "overridden"), awaitKeys(events, 2)); + assertNull(events.poll(200, TimeUnit.MILLISECONDS)); + } + } } 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 index a1acc3ca..de4f46cb 100644 --- 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 @@ -1,6 +1,7 @@ package com.launchdarkly.sdk.server; import com.launchdarkly.sdk.server.DataModel.FeatureFlag; +import com.launchdarkly.sdk.server.DataModelDependencies.KindAndKey; import com.launchdarkly.sdk.server.DataModel.Segment; import com.launchdarkly.sdk.server.subsystems.DataStoreTypes.DataKind; import com.launchdarkly.sdk.server.subsystems.DataStoreTypes.ItemDescriptor; @@ -13,10 +14,15 @@ import java.util.Collections; import java.util.List; import java.util.Map; +import java.util.Set; +import java.util.HashSet; 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.ruleBuilder; +import static com.launchdarkly.sdk.server.ModelBuilders.prerequisite; +import static com.launchdarkly.sdk.server.ModelBuilders.clauseMatchingSegment; import static com.launchdarkly.sdk.server.ModelBuilders.segmentBuilder; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; @@ -132,4 +138,44 @@ public void allReturnsEntriesOfKindOnly() { assertTrue(layer.all(FEATURES).containsKey("f")); assertTrue(layer.all(SEGMENTS).containsKey("s")); } + + private static Set affectedFrom(OverrideLayer layer, DataKind kind, String key) { + Set affected = new HashSet<>(); + layer.addAffectedItems(affected, new KindAndKey(kind, key)); + return affected; + } + + private static Set items(KindAndKey... items) { + return new HashSet<>(java.util.Arrays.asList(items)); + } + + @Test + public void reportsEntriesThatDependOnAnItem() { + // a matches a segment; b has a as a prerequisite, so b depends on the segment through a. + FeatureFlag a = flagBuilder("a").version(1).on(true) + .rules(ruleBuilder().id("r").variation(0).clauses(clauseMatchingSegment("seg")).build()).build(); + FeatureFlag b = flagBuilder("b").version(1).on(true).prerequisites(prerequisite("a", 0)).build(); + OverrideLayer layer = new OverrideLayer(); + layer.setAll(flagsOnly(a, b)); + + assertEquals(items(new KindAndKey(SEGMENTS, "seg"), new KindAndKey(FEATURES, "a"), new KindAndKey(FEATURES, "b")), + affectedFrom(layer, SEGMENTS, "seg")); + assertEquals(items(new KindAndKey(FEATURES, "a"), new KindAndKey(FEATURES, "b")), + affectedFrom(layer, FEATURES, "a")); + assertEquals(items(new KindAndKey(SEGMENTS, "unreferenced")), affectedFrom(layer, SEGMENTS, "unreferenced")); + } + + @Test + public void forgetsEdgesOfReplacedEntries() { + FeatureFlag a = flagBuilder("a").version(1).on(true) + .rules(ruleBuilder().id("r").variation(0).clauses(clauseMatchingSegment("seg")).build()).build(); + FeatureFlag c = flagBuilder("c").version(1).on(true) + .rules(ruleBuilder().id("r").variation(0).clauses(clauseMatchingSegment("seg2")).build()).build(); + OverrideLayer layer = new OverrideLayer(); + layer.setAll(flagsOnly(a)); + layer.setAll(flagsOnly(c)); + + assertEquals(items(new KindAndKey(SEGMENTS, "seg")), affectedFrom(layer, SEGMENTS, "seg")); + assertEquals(items(new KindAndKey(SEGMENTS, "seg2"), new KindAndKey(FEATURES, "c")), affectedFrom(layer, SEGMENTS, "seg2")); + } } From ff37ce33e9c565e6509cf5fad40d75e6c31e0c30 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Wed, 28 Oct 2026 22:10:35 +0000 Subject: [PATCH 09/30] fix: Build the overrides-only flag state from the layer and keep a failing source from stopping the client allFlagsState returns a state of overrides alone only when the store is not initialized and the override layer holds flags, and it reads those flags from the layer itself. Before, any layer contents were enough: a layer of segments only over a store that held LaunchDarkly data without an init marker produced a valid state of that data, and with override flags present the same data entered the state. The overlay no longer serves the layer's entries alone when the base store fails to list its items, so a client whose store fails reports an invalid state and logs the failure, as it does without a source. An exception from the source's start no longer fails client construction. The SDK logs it, closes the source, clears what the source supplied before failing, and the client runs without overrides. Closing the client stops the sink from accepting snapshots before it closes the source, so a snapshot supplied during shutdown is ignored; an exception from the source's close is still logged rather than thrown. An offline client still does not build or start the override source, and now logs a warning that it does not. --- .../sdk/server/FDv2DataSystem.java | 54 ++++- .../sdk/server/InputValidatingEvaluator.java | 29 ++- .../sdk/server/OverrideOverlayStore.java | 24 +-- .../sdk/server/OverrideSinkImpl.java | 29 +++ .../integrations/DataSystemBuilder.java | 4 + .../sdk/server/subsystems/OverrideSource.java | 8 +- .../sdk/server/LDClientOverridesTest.java | 197 ++++++++++++++++++ .../sdk/server/OverrideOverlayStoreTest.java | 19 +- .../sdk/server/OverrideSinkImplTest.java | 35 ++++ .../sdk/server/TestOverrideSource.java | 25 +++ 10 files changed, 380 insertions(+), 44 deletions(-) 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 67d46139..bafad08f 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 @@ -42,8 +42,9 @@ final class FDv2DataSystem implements DataSystem, Closeable { // 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; + // Null after the source failed to start or was closed. + private volatile OverrideSource overrideSource; private boolean disposed = false; private FDv2DataSystem( @@ -216,8 +217,12 @@ static FDv2DataSystem create( // 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); + if (dataSystemConfiguration.getOverrideSource() != null) { + if (config.offline) { + logger.warn("Offline mode is enabled; the override source is not started and no overrides are served"); + } else { + overrideSource = dataSystemConfiguration.getOverrideSource().build(clientContext); + } } FDv2DataSystem system = new FDv2DataSystem( @@ -247,8 +252,23 @@ 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); + // starting the client. A source that fails to start does not prevent the client from + // operating: the failure is logged, the source is closed, and the client runs without + // overrides. The SDK's own file-based source does not throw from start; it logs a failed + // load and retries it. A custom source could throw, and an application in an incident is + // better served by a client that runs without its overrides than by one that does not start. + try { + overrideSource.start(overrideSink); + } catch (RuntimeException e) { + logger.error("Unable to start the override source; the client runs without overrides: {}", + LogValues.exceptionSummary(e)); + logger.debug(LogValues.exceptionTrace(e)); + closeOverrideSource("Unable to close the override source after it failed to start: {}"); + // The source may have supplied a snapshot before it failed. Clearing the layer removes it, + // so the client runs without overrides as logged, and a late snapshot from the closed + // source is ignored. + overrideSink.closeAndClear(); + } } return dataSource.start(); } @@ -278,6 +298,19 @@ public String getEnvironmentId() { return store.getEnvironmentId(); } + // A custom source can throw from close. That is logged and not thrown, so the rest of the client + // still shuts down, or still starts after a failed start. + private void closeOverrideSource(String failureMessage) { + OverrideSource source = overrideSource; + overrideSource = null; + try { + source.close(); + } catch (IOException | RuntimeException e) { + logger.warn(failureMessage, LogValues.exceptionSummary(e)); + logger.debug(LogValues.exceptionTrace(e)); + } + } + @Override public OverrideLayer getOverrideLayer() { return overrideLayer; @@ -290,12 +323,11 @@ public void close() throws IOException { } try { if (overrideSource != null) { - try { - overrideSource.close(); - } catch (IOException | RuntimeException e) { - logger.warn("Error closing override source: {}", LogValues.exceptionSummary(e)); - logger.debug(LogValues.exceptionTrace(e)); - } + // The sink stops accepting snapshots first, so a snapshot that the source supplies while it + // shuts down cannot update the layer or notify listeners through components that are being + // closed. + overrideSink.close(); + closeOverrideSource("Unable to close the override source: {}"); } dataSource.close(); store.close(); 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 7c78e5a8..cfc089a0 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 @@ -13,6 +13,7 @@ import javax.annotation.Nonnull; import javax.annotation.Nullable; +import java.util.Collections; import java.util.Map; import java.util.concurrent.atomic.AtomicBoolean; @@ -177,10 +178,15 @@ EvalResultAndFlag evaluate(String flagKey, LDContext context, LDValue defaultVal public FeatureFlagsState allFlagsState(LDContext context, FlagsStateOption... options) { FeatureFlagsState.Builder builder = FeatureFlagsState.builder(options); + DataStoreTypes.KeyedItems overrideFlags = null; if (!store.isInitialized()) { - // 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()) { + // No LaunchDarkly data is available. When the override layer holds flags, the state contains + // only those, read from the layer itself: a store that is not initialized can still hold + // LaunchDarkly data, which the state must not include. Without override flags the state is + // invalid, as it is without an override source. + Map layerFlags = + overrideLayer == null ? Collections.emptyMap() : overrideLayer.all(FEATURES); + if (layerFlags.isEmpty()) { logger.warn("allFlagsState() was called before client initialized; data store unavailable, returning no data"); return builder.valid(false).build(); } @@ -188,6 +194,7 @@ public FeatureFlagsState allFlagsState(LDContext context, FlagsStateOption... op logger.warn("allFlagsState() was called before client initialized; returning only flags from the override layer." + " This message is logged once."); } + overrideFlags = new DataStoreTypes.KeyedItems<>(layerFlags.entrySet()); } if (context == null) { @@ -201,12 +208,16 @@ public FeatureFlagsState allFlagsState(LDContext context, FlagsStateOption... op boolean clientSideOnly = FlagsStateOption.hasOption(options, FlagsStateOption.CLIENT_SIDE_ONLY); DataStoreTypes.KeyedItems flags; - try { - flags = store.getAll(FEATURES); - } catch (Exception e) { - logger.error("Exception from data store when evaluating all flags: {}", LogValues.exceptionSummary(e)); - logger.debug(e.toString(), LogValues.exceptionTrace(e)); - return builder.valid(false).build(); + if (overrideFlags != null) { + flags = overrideFlags; + } else { + try { + flags = store.getAll(FEATURES); + } catch (Exception e) { + logger.error("Exception from data store when evaluating all flags: {}", LogValues.exceptionSummary(e)); + logger.debug(e.toString(), LogValues.exceptionTrace(e)); + return builder.valid(false).build(); + } } for (Map.Entry entry : flags.getItems()) { 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 index f80f45bf..5901145e 100644 --- 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 @@ -45,28 +45,18 @@ public ItemDescriptor get(DataKind kind, String 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. + * any key present in both, including keys that the base holds as deleted-item placeholders. A + * failure of the base store propagates, as it does without the overlay. */ @Override public KeyedItems getAll(DataKind kind) { + KeyedItems fromBase = base.getAll(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(); + if (overrides.isEmpty()) { + return fromBase; } + Iterable> baseItems = + fromBase == null || fromBase.getItems() == null ? Collections.emptyList() : fromBase.getItems(); ImmutableList.Builder> result = ImmutableList.builder(); Set seen = new HashSet<>(); 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 index a18f9f95..280eed07 100644 --- 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 @@ -11,6 +11,7 @@ import com.launchdarkly.sdk.server.subsystems.DataStoreTypes.KeyedItems; import com.launchdarkly.sdk.server.subsystems.OverrideSink; +import java.util.Collections; import java.util.HashMap; import java.util.LinkedHashSet; import java.util.Map; @@ -33,6 +34,7 @@ final class OverrideSinkImpl implements OverrideSink { private final ReadOnlyStore base; private final EventBroadcasterImpl flagChangeBroadcaster; private final LDLogger logger; + private boolean closed; // guarded by this /** * Creates a sink that writes to the given layer. @@ -62,6 +64,33 @@ final class OverrideSinkImpl implements OverrideSink { */ @Override public synchronized void setOverrides(Iterable>> data) { + if (closed) { + logger.debug("Ignoring an override snapshot supplied after the override source was closed"); + return; + } + apply(data); + } + + /** + * Stops accepting snapshots. Used when the client is closed: a snapshot that the source supplies + * while it shuts down is ignored. The layer keeps its contents. + */ + synchronized void close() { + closed = true; + } + + /** + * Stops accepting snapshots and clears the layer. Used when the source failed to start: a snapshot + * the source supplied before failing is removed, so the client runs without overrides, and a late + * snapshot from the closed source is ignored. + */ + synchronized void closeAndClear() { + closed = true; + apply(Collections.emptyList()); + } + + // Called while holding the lock on this. + private void apply(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()) { 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 b07cc037..72ad5097 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 @@ -123,6 +123,10 @@ public DataSystemBuilder persistentStore(ComponentConfigurer persiste * override. At most one override source can be configured; a later call replaces the earlier one. *

*

+ * When the client is offline, the source is not built or started: no overrides are served, and a + * configuration error in the source is not reported. The SDK logs a warning instead. + *

+ *

* Example: *

*

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
index 0e83f01e..06c9389a 100644
--- 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
@@ -14,6 +14,9 @@
  * 

* To configure an override source, use * {@link com.launchdarkly.sdk.server.integrations.DataSystemBuilder#overrides(ComponentConfigurer)}. + * The SDK closes the source when the client is closed. An exception thrown from {@link #close()} is + * logged and does not stop the client from shutting down. A snapshot that the source supplies after + * the client begins to close it is ignored. *

* Flag overrides are currently experimental and subject to change. * @@ -28,7 +31,10 @@ public interface OverrideSource extends Closeable { * 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. + * is being constructed. An exception thrown from it does not prevent the client from starting: the + * SDK logs the exception, closes the source, and the client runs without overrides. A source that + * cannot load its data should log that and retry rather than throw, as the SDK's file-based source + * does. * * @param sink receives override snapshots */ 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 index 264096db..6d32874a 100644 --- 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 @@ -12,10 +12,14 @@ import com.launchdarkly.sdk.server.integrations.DataSystemBuilder; import com.launchdarkly.sdk.server.integrations.TestDataV2; import com.launchdarkly.sdk.server.interfaces.FlagChangeEvent; +import com.launchdarkly.sdk.server.interfaces.DataStoreStatusProvider.CacheStats; import com.launchdarkly.sdk.server.subsystems.ComponentConfigurer; +import com.launchdarkly.sdk.server.subsystems.DataStore; import com.launchdarkly.sdk.server.subsystems.DataStoreTypes.DataKind; +import com.launchdarkly.sdk.server.subsystems.DataStoreTypes.FullDataSet; import com.launchdarkly.sdk.server.subsystems.DataStoreTypes.ItemDescriptor; import com.launchdarkly.sdk.server.subsystems.DataStoreTypes.KeyedItems; +import com.launchdarkly.sdk.server.subsystems.DataSystemConfiguration.DataStoreMode; import com.launchdarkly.sdk.server.subsystems.OverrideSource; import org.junit.Test; @@ -43,6 +47,7 @@ import static org.hamcrest.Matchers.not; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertNull; import static org.junit.Assert.assertTrue; import static org.junit.Assert.fail; @@ -186,6 +191,66 @@ public void allFlagsStateIsInvalidWhenNotInitializedAndOverrideLayerIsEmpty() th } } + // A client that reads LaunchDarkly data from the given store and never initializes. + private LDConfig uninitializedConfigWithStore(DataStore store, ComponentConfigurer source) { + return configWith(Components.dataSystem().custom().synchronizers(hangingSynchronizer()) + .persistentStore(context -> store, DataStoreMode.READ_ONLY).overrides(source)) + .startWait(Duration.ZERO) + .build(); + } + + @Test + public void allFlagsStateWithoutOverrideFlagsIgnoresDataInAStoreWithoutAnInitMarker() throws Exception { + // A layer that holds no flags changes nothing: the state is invalid, as it is without a source, + // and does not report the LaunchDarkly data in the store. That holds for an empty layer and for + // one that holds only segments. + FixedDataStore store = new FixedDataStore(flagsOnly(valueFlag("launchdarkly-flag", "ld-value")), false); + TestOverrideSource emptySource = new TestOverrideSource(Collections.emptyList()); + try (LDClient client = new LDClient(SDK_KEY, uninitializedConfigWithStore(store, emptySource))) { + FeatureFlagsState state = client.allFlagsState(CONTEXT); + assertFalse(state.isValid()); + assertEquals(Collections.emptyMap(), state.toValuesMap()); + } + TestOverrideSource segmentSource = new TestOverrideSource(OverrideLayerTest.collections( + Collections.emptyList(), Collections.singletonList(segmentBuilder("segment").version(1).build()))); + try (LDClient client = new LDClient(SDK_KEY, uninitializedConfigWithStore(store, segmentSource))) { + FeatureFlagsState state = client.allFlagsState(CONTEXT); + assertFalse(state.isValid()); + assertEquals(Collections.emptyMap(), state.toValuesMap()); + } + } + + @Test + public void allFlagsStateBeforeInitializationExcludesLaunchDarklyDataFromAStoreWithoutAnInitMarker() throws Exception { + FixedDataStore store = new FixedDataStore(flagsOnly(valueFlag("launchdarkly-flag", "ld-value")), false); + TestOverrideSource source = new TestOverrideSource(flagsOnly(valueFlag("overridden", "override-value"))); + try (LDClient client = new LDClient(SDK_KEY, uninitializedConfigWithStore(store, source))) { + FeatureFlagsState state = client.allFlagsState(CONTEXT); + assertTrue(state.isValid()); + assertEquals(Collections.singletonMap("overridden", LDValue.of("override-value")), state.toValuesMap()); + } + } + + @Test + public void allFlagsStateIsInvalidWhenTheStoreFailsEvenWithOverrides() throws Exception { + // A daemon-mode client whose store holds an init marker but cannot list its flags reports an + // invalid state and logs the failure, as it does without a source, rather than a valid state that + // holds only the overrides. + FixedDataStore store = new FixedDataStore(flagsOnly(), true); + store.getAllError = new RuntimeException("store failed"); + TestOverrideSource source = new TestOverrideSource(flagsOnly(valueFlag("overridden", "override-value"))); + LDConfig config = configWith(Components.dataSystem().daemon(context -> store).overrides(source)) + .startWait(Duration.ZERO) + .build(); + try (LDClient client = new LDClient(SDK_KEY, config)) { + FeatureFlagsState state = client.allFlagsState(CONTEXT); + assertFalse(state.isValid()); + assertEquals(Collections.emptyMap(), state.toValuesMap()); + assertTrue(logCapture.getMessageStrings().stream().anyMatch(m -> + m.startsWith("ERROR:Exception from data store when evaluating all flags") && m.contains("store failed"))); + } + } + @Test public void allFlagsStateTurnsOffEventTrackingForOverrideAffectedFlags() throws Exception { long debugUntil = System.currentTimeMillis() + 100000; @@ -308,6 +373,66 @@ public void overrideSourceIsStartedBeforeConstructorReturnsAndClosedWithClient() assertTrue(source.closed.get()); } + @Test + public void overrideSourceStartFailureIsLoggedAndTheClientRunsWithoutOverrides() throws Exception { + // The SDK's own source never throws from start. A custom source could, and that must not keep + // the client from starting: the failure is logged, the source is closed, and the client runs + // without overrides. + TestOverrideSource source = new TestOverrideSource(flagsOnly(valueFlag("flag", "override-value"))); + source.startError = new RuntimeException("source failed"); + try (LDClient client = new LDClient(SDK_KEY, uninitializedConfig(source))) { + assertTrue(source.closed.get()); + assertTrue(logCapture.getMessageStrings().stream().anyMatch(m -> + m.startsWith("ERROR:Unable to start the override source; the client runs without overrides") + && m.contains("source failed"))); + assertNotReady(client.jsonValueVariationDetail("flag", CONTEXT, DEFAULT)); + } + } + + @Test + public void overrideSourceThatSuppliesASnapshotAndThenFailsToStartLeavesNoOverrides() throws Exception { + // The snapshot the source supplied before it failed is removed with the source, so the client + // runs without overrides, as the log says, and its all-flags state agrees. + TestOverrideSource source = new TestOverrideSource(flagsOnly(valueFlag("flag", "override-value"))); + source.startError = new RuntimeException("source failed"); + source.supplyDataBeforeStartError = true; + try (LDClient client = new LDClient(SDK_KEY, uninitializedConfig(source))) { + assertTrue(source.closed.get()); + assertNotReady(client.jsonValueVariationDetail("flag", CONTEXT, DEFAULT)); + assertFalse(client.allFlagsState(CONTEXT).isValid()); + } + } + + @Test + public void overrideSourceCloseFailureDoesNotStopTheClientFromShuttingDown() throws Exception { + FixedDataStore store = new FixedDataStore(flagsOnly(), false); + TestOverrideSource source = new TestOverrideSource(Collections.emptyList()); + source.closeError = new RuntimeException("close failed"); + LDConfig config = configWith(Components.dataSystem().custom().synchronizers(hangingSynchronizer()) + .persistentStore(context -> store, DataStoreMode.READ_WRITE).overrides(source)) + .startWait(Duration.ZERO) + .build(); + LDClient client = new LDClient(SDK_KEY, config); + + client.close(); + + assertTrue(source.closed.get()); + assertTrue(store.closed); + assertTrue(logCapture.getMessageStrings().stream().anyMatch(m -> + m.startsWith("WARN:Unable to close the override source") && m.contains("close failed"))); + } + + @Test + public void snapshotSuppliedAfterTheClientIsClosedIsIgnored() throws Exception { + TestOverrideSource source = new TestOverrideSource(Collections.emptyList()); + LDClient client = new LDClient(SDK_KEY, uninitializedConfig(source)); + client.close(); + + source.deliverEvenIfClosed(flagsOnly(valueFlag("late-flag", "override-value"))); + + assertNull(client.dataSystem.getOverrideLayer().get(DataModel.FEATURES, "late-flag")); + } + @Test public void overrideSourceIsBuiltWithClientContext() throws Exception { TestOverrideSource source = new TestOverrideSource(Collections.emptyList()); @@ -324,6 +449,8 @@ public void offlineClientDoesNotStartOverrideSource() throws Exception { assertFalse(source.started.get()); assertNull(client.dataSystem.getOverrideLayer()); assertEquals(DEFAULT, client.jsonValueVariation("flag", CONTEXT, DEFAULT)); + assertThat(logCapture.getMessageStrings(), hasItem( + "WARN:Offline mode is enabled; the override source is not started and no overrides are served")); } assertFalse(source.closed.get()); } @@ -410,4 +537,74 @@ public void launchDarklyChangeNotifiesOverridesThatDependOnIt() throws Exception assertNull(events.poll(200, TimeUnit.MILLISECONDS)); } } + + // A store with fixed contents. Constructed not initialized, it is like a persistent store that holds + // LaunchDarkly data but no init marker. getAll throws getAllError when it is set. + private static final class FixedDataStore implements DataStore { + private final Iterable>> data; + private final boolean initialized; + volatile RuntimeException getAllError; + volatile boolean closed; + + FixedDataStore(Iterable>> data, boolean initialized) { + this.data = data; + this.initialized = initialized; + } + + @Override + public void init(FullDataSet allData) { + } + + @Override + public ItemDescriptor get(DataKind kind, String key) { + for (Map.Entry e : items(kind)) { + if (e.getKey().equals(key)) { + return e.getValue(); + } + } + return null; + } + + @Override + public KeyedItems getAll(DataKind kind) { + if (getAllError != null) { + throw getAllError; + } + return new KeyedItems<>(items(kind)); + } + + @Override + public boolean upsert(DataKind kind, String key, ItemDescriptor item) { + return true; + } + + @Override + public boolean isInitialized() { + return initialized; + } + + @Override + public boolean isStatusMonitoringEnabled() { + return false; + } + + @Override + public CacheStats getCacheStats() { + return null; + } + + @Override + public void close() { + closed = true; + } + + private Iterable> items(DataKind kind) { + for (Map.Entry> e : data) { + if (e.getKey() == kind) { + return e.getValue().getItems(); + } + } + return Collections.emptyList(); + } + } } 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 index 4206f49c..2edb0982 100644 --- 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 @@ -152,16 +152,23 @@ public void getAllWithEmptyLayerReturnsBaseResultAsIs() { } @Test - public void getAllServesOverridesWhenBaseFails() { + public void getAllPropagatesBaseFailureWhenLayerHoldsEntries() { + // A result of the layer's entries alone would look complete to a caller such as the all-flags + // state, so the failure propagates as it does without the overlay. A per-key read still serves + // the override. FakeBaseStore base = new FakeBaseStore(); - base.getAllError = new RuntimeException("store down"); + RuntimeException error = new RuntimeException("store down"); + base.getAllError = error; 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()); + try { + overlay.getAll(FEATURES); + fail("expected exception"); + } catch (RuntimeException e) { + assertSame(error, e); + } + assertTrue(flagOf(overlay.get(FEATURES, "flag")).isOverride()); } @Test 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 index 45e5061e..ca7a3916 100644 --- 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 @@ -26,6 +26,7 @@ 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.assertTrue; @@ -72,6 +73,40 @@ public void setOverridesReplacesLayer() { assertTrue(layer.isEmpty()); } + @Test + public void closeKeepsTheLayerAndIgnoresLaterSnapshots() throws Exception { + listen(); + sink.setOverrides(flagsOnly(flagBuilder("a").version(1).build())); + assertEquals(keys("a"), awaitKeys(1)); + + sink.close(); + assertNotNull(layer.get(DataModel.FEATURES, "a")); + assertNoMoreEvents(); + + // A snapshot after closing changes nothing. + sink.setOverrides(flagsOnly(flagBuilder("b").version(1).build())); + assertNotNull(layer.get(DataModel.FEATURES, "a")); + assertNull(layer.get(DataModel.FEATURES, "b")); + assertNoMoreEvents(); + } + + @Test + public void closeAndClearClearsTheLayerAndIgnoresLaterSnapshots() throws Exception { + listen(); + sink.setOverrides(flagsOnly(flagBuilder("a").version(1).build())); + assertEquals(keys("a"), awaitKeys(1)); + + // Closing removes what the source supplied, and the removal is a change. + sink.closeAndClear(); + assertTrue(layer.isEmpty()); + assertEquals(keys("a"), awaitKeys(1)); + + // A snapshot after closing changes nothing. + sink.setOverrides(flagsOnly(flagBuilder("a").version(1).build())); + assertTrue(layer.isEmpty()); + assertNoMoreEvents(); + } + @Test public void notifiesOnAddChangeAndRemove() throws Exception { listen(); 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 index 02af3be2..3d4e1853 100644 --- 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 @@ -22,6 +22,12 @@ public final class TestOverrideSource implements OverrideSource, ComponentConfig final AtomicBoolean started = new AtomicBoolean(false); final AtomicBoolean closed = new AtomicBoolean(false); volatile ClientContext buildContext; + // When set, start throws it instead of supplying data. + volatile RuntimeException startError; + // When set with startError, start supplies the data to the sink and then throws. + volatile boolean supplyDataBeforeStartError; + // When set, close throws it after recording the close. + volatile RuntimeException closeError; public TestOverrideSource(Iterable>> initialData) { this.initialData = initialData; @@ -35,6 +41,12 @@ public OverrideSource build(ClientContext clientContext) { @Override public void start(OverrideSink sink) { + if (startError != null) { + if (supplyDataBeforeStartError && initialData != null) { + sink.setOverrides(initialData); + } + throw startError; + } this.sink = sink; started.set(true); if (initialData != null) { @@ -54,8 +66,21 @@ public void setOverrides(Iterable } } + /** + * Supplies a snapshot to the sink even after the source was closed, as a misbehaving source or a + * load that races the close could. + * + * @param data the complete set of override entries + */ + public void deliverEvenIfClosed(Iterable>> data) { + sink.setOverrides(data); + } + @Override public void close() { closed.set(true); + if (closeError != null) { + throw closeError; + } } } From c680ae748cffe47ebbf691cbdefe595d9e90a7f7 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Wed, 28 Oct 2026 22:35:22 +0000 Subject: [PATCH 10/30] docs: State that an override source's start is not intended to fail A source logs and retries a failed load, and reports a configuration error from build. If start throws anyway, the SDK logs it, closes the source, removes its snapshot, and the client runs without overrides. --- .../sdk/server/subsystems/OverrideSource.java | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) 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 index 06c9389a..dbaf0452 100644 --- 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 @@ -31,10 +31,12 @@ public interface OverrideSource extends Closeable { * 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. An exception thrown from it does not prevent the client from starting: the - * SDK logs the exception, closes the source, and the client runs without overrides. A source that - * cannot load its data should log that and retry rather than throw, as the SDK's file-based source - * does. + * is being constructed. This method is not intended to fail. A source that cannot load its data + * logs the failure and keeps retrying, as the SDK's file-based source does. A configuration error + * belongs in the {@link ComponentConfigurer#build(ClientContext)} that builds the source, where it + * is reported when the client is created. If this method does throw, the client still starts: the + * SDK logs the exception, closes the source, removes any snapshot the source supplied, and the + * client runs without overrides. * * @param sink receives override snapshots */ From 77ef6f4369f7db98d063fa6ae4f140a576dedf62 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Thu, 29 Oct 2026 16:44:50 +0000 Subject: [PATCH 11/30] fix: Keep the override flags in the all flags state when the store fails When the store cannot list its flags, the state without overrides is invalid and empty. Overrides only add to it, so the state now holds the override layer's flags and stays invalid, and the failure is logged as before. A client-side SDK bootstrapped from the state uses its flags whatever $valid says, so the overridden values still reach it. --- .../sdk/server/InputValidatingEvaluator.java | 15 +++++++-- .../sdk/server/LDClientOverridesTest.java | 33 ++++++++++++++----- 2 files changed, 38 insertions(+), 10 deletions(-) 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 cfc089a0..8e8cd6fa 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 @@ -208,6 +208,7 @@ public FeatureFlagsState allFlagsState(LDContext context, FlagsStateOption... op boolean clientSideOnly = FlagsStateOption.hasOption(options, FlagsStateOption.CLIENT_SIDE_ONLY); DataStoreTypes.KeyedItems flags; + boolean valid = true; if (overrideFlags != null) { flags = overrideFlags; } else { @@ -216,7 +217,17 @@ public FeatureFlagsState allFlagsState(LDContext context, FlagsStateOption... op } catch (Exception e) { logger.error("Exception from data store when evaluating all flags: {}", LogValues.exceptionSummary(e)); logger.debug(e.toString(), LogValues.exceptionTrace(e)); - return builder.valid(false).build(); + // Overrides only add to the state. Without them the state is invalid and empty here, so it + // holds the override layer's flags and stays invalid. A client-side SDK bootstrapped from the + // state uses its flags whatever $valid says (js-client-sdk 3.x and the 4.x browser SDK only + // log a warning), so the overridden values still reach it. + Map layerFlags = + overrideLayer == null ? Collections.emptyMap() : overrideLayer.all(FEATURES); + if (layerFlags.isEmpty()) { + return builder.valid(false).build(); + } + flags = new DataStoreTypes.KeyedItems<>(layerFlags.entrySet()); + valid = false; } } @@ -240,7 +251,7 @@ public FeatureFlagsState allFlagsState(LDContext context, FlagsStateOption... op builder.addFlag(flag, EvalResult.of(LDValue.ofNull(), NO_VARIATION, EvaluationReason.exception(e))); } } - return builder.build(); + return builder.valid(valid).build(); } private static DataModel.FeatureFlag getFlag(ReadOnlyStore store, String key) { 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 index 6d32874a..11b79578 100644 --- 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 @@ -232,17 +232,28 @@ public void allFlagsStateBeforeInitializationExcludesLaunchDarklyDataFromAStoreW } @Test - public void allFlagsStateIsInvalidWhenTheStoreFailsEvenWithOverrides() throws Exception { - // A daemon-mode client whose store holds an init marker but cannot list its flags reports an - // invalid state and logs the failure, as it does without a source, rather than a valid state that - // holds only the overrides. + public void allFlagsStateWhenTheStoreFailsHoldsTheOverridesAndIsInvalid() throws Exception { + // A daemon-mode client whose store holds an init marker but cannot list its flags logs the + // failure. Without a source the state is invalid and empty; the overrides add to it, so it holds + // the overridden flags and stays invalid. FixedDataStore store = new FixedDataStore(flagsOnly(), true); store.getAllError = new RuntimeException("store failed"); TestOverrideSource source = new TestOverrideSource(flagsOnly(valueFlag("overridden", "override-value"))); - LDConfig config = configWith(Components.dataSystem().daemon(context -> store).overrides(source)) - .startWait(Duration.ZERO) - .build(); - try (LDClient client = new LDClient(SDK_KEY, config)) { + try (LDClient client = new LDClient(SDK_KEY, daemonConfig(store, source))) { + FeatureFlagsState state = client.allFlagsState(CONTEXT, FlagsStateOption.WITH_REASONS); + assertFalse(state.isValid()); + assertEquals(Collections.singletonMap("overridden", LDValue.of("override-value")), state.toValuesMap()); + assertTrue(state.getFlagReason("overridden").isOverrideAffected()); + assertTrue(logCapture.getMessageStrings().stream().anyMatch(m -> + m.startsWith("ERROR:Exception from data store when evaluating all flags") && m.contains("store failed"))); + } + } + + @Test + public void allFlagsStateWhenTheStoreFailsAndTheLayerHoldsNoFlagsIsInvalidAndEmpty() throws Exception { + FixedDataStore store = new FixedDataStore(flagsOnly(), true); + store.getAllError = new RuntimeException("store failed"); + try (LDClient client = new LDClient(SDK_KEY, daemonConfig(store, new TestOverrideSource(Collections.emptyList())))) { FeatureFlagsState state = client.allFlagsState(CONTEXT); assertFalse(state.isValid()); assertEquals(Collections.emptyMap(), state.toValuesMap()); @@ -251,6 +262,12 @@ public void allFlagsStateIsInvalidWhenTheStoreFailsEvenWithOverrides() throws Ex } } + private LDConfig daemonConfig(DataStore store, TestOverrideSource source) { + return configWith(Components.dataSystem().daemon(context -> store).overrides(source)) + .startWait(Duration.ZERO) + .build(); + } + @Test public void allFlagsStateTurnsOffEventTrackingForOverrideAffectedFlags() throws Exception { long debugUntil = System.currentTimeMillis() + 100000; From e95c7a9f131c2039e6be1ba3bbd5d993751005a4 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Thu, 29 Oct 2026 16:55:05 +0000 Subject: [PATCH 12/30] fix: Mark the EXCEPTION result of an overridden flag in the all flags state When the evaluation of an overridden flag throws during allFlagsState, the error result now carries the marking of the flag's own definition, so the state presents its marked reason with event tracking off, as for any override-affected flag. --- .../sdk/server/InputValidatingEvaluator.java | 5 ++++- .../InputValidatingEvaluatorOverrideTest.java | 19 +++++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) 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 8e8cd6fa..811af12c 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 @@ -248,7 +248,10 @@ public FeatureFlagsState allFlagsState(LDContext context, FlagsStateOption... op logger.error("Exception caught for feature flag \"{}\" when evaluating all flags: {}", flag.getKey(), LogValues.exceptionSummary(e)); logger.debug(e.toString(), LogValues.exceptionTrace(e)); - builder.addFlag(flag, EvalResult.of(LDValue.ofNull(), NO_VARIATION, EvaluationReason.exception(e))); + // The flag's definition was read before the failure, so its own marker decides the marking, + // and with it the state's event tracking fields for the flag. + builder.addFlag(flag, EvalResult.of(LDValue.ofNull(), NO_VARIATION, EvaluationReason.exception(e)) + .withOverrideAffected(flag.isOverride())); } } return builder.valid(valid).build(); 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 e86a65db..8ffdd167 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 @@ -4,6 +4,7 @@ import com.launchdarkly.sdk.EvaluationReason.ErrorKind; import com.launchdarkly.sdk.LDValue; import com.launchdarkly.sdk.LDValueType; +import com.launchdarkly.sdk.json.JsonSerialization; import com.launchdarkly.sdk.server.DataModel.FeatureFlag; import com.launchdarkly.sdk.server.subsystems.DataStoreTypes.DataKind; import com.launchdarkly.sdk.server.subsystems.DataStoreTypes.ItemDescriptor; @@ -154,6 +155,24 @@ public void errorResultOfOverrideFlagKeepsMarkingWithCallerDefault() { assertEquals(EvaluationReason.error(ErrorKind.MALFORMED_FLAG).withOverrideAffected(true), result.getResult().getReason()); } + @Test + public void allFlagsStateMarksTheExceptionResultOfAnOverrideFlagAndTurnsOffItsTracking() { + FeatureFlag marked = flagWithPrerequisite("marked").markedAsOverride(); + FeatureFlag plain = flagWithPrerequisite("plain"); + FeatureFlagsState state = evaluatorOver(storeFailingOn("prereq", marked, plain)) + .allFlagsState(BASE_USER, FlagsStateOption.WITH_REASONS); + LDValue flagsState = LDValue.parse(JsonSerialization.serialize(state)).get("$flagsState"); + + assertEquals(EvaluationReason.ErrorKind.EXCEPTION, state.getFlagReason("marked").getErrorKind()); + assertTrue(state.getFlagReason("marked").isOverrideAffected()); + assertTrue(flagsState.get("marked").get("trackEvents").isNull()); + + // A flag that did not come from the override store keeps its unmarked reason and tracking. + assertEquals(EvaluationReason.ErrorKind.EXCEPTION, state.getFlagReason("plain").getErrorKind()); + assertFalse(state.getFlagReason("plain").isOverrideAffected()); + assertEquals(LDValue.of(true), flagsState.get("plain").get("trackEvents")); + } + @Test public void allFlagsStateKeepsMarkedReason() { FeatureFlag marked = flagBuilder("marked").on(false).offVariation(0).variations(LDValue.of("x")).build() From e68252b2e3527ea2e770b5f9a33f65e5a48e07f0 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Fri, 25 Sep 2026 15:48:36 -0700 Subject: [PATCH 13/30] feat: Suppress individual events and split summary counters for override-affected evaluations The OVERRIDE specification requires an override-affected evaluation to produce no individual feature event and no debug event, whatever the flag requests, while still being counted in summary events under a separate counter that carries the overrideAffected marker. A prerequisite record carries the prerequisite's own marking. EventProcessor gains an overload of recordEvaluationEvent with the marking. It is a default method that delegates to the existing method, so custom implementations keep compiling. The SDK's implementations override it and the evaluator's event recorder passes the evaluation result's marking for top-level, prerequisite, and requested-type mismatch records. Event generation keys on that value alone. The server SDK now depends on launchdarkly-java-sdk-internal 1.13.0, which carries the marker through Event.FeatureRequest, the summarizer, the output formatter, and the event processor. CI for this change cannot pass until that version is released. The specification's vector runner now also asserts the per-evaluation summary marking. Flag overrides are currently experimental and subject to change. --- lib/sdk/server/build.gradle | 2 +- .../server/DefaultEventProcessorWrapper.java | 12 +- .../sdk/server/EvaluationRecorder.java | 3 +- .../sdk/server/InputValidatingEvaluator.java | 24 +- .../sdk/server/NoOpEventProcessor.java | 8 + .../sdk/server/subsystems/EventProcessor.java | 48 ++++ .../server/LDClientOverrideEventsTest.java | 271 ++++++++++++++++++ .../sdk/server/OverrideSpecVectorsTest.java | 25 +- .../sdk/server/TestComponents.java | 11 +- 9 files changed, 391 insertions(+), 13 deletions(-) create mode 100644 lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/LDClientOverrideEventsTest.java diff --git a/lib/sdk/server/build.gradle b/lib/sdk/server/build.gradle index 399c78df..4a2c1537 100644 --- a/lib/sdk/server/build.gradle +++ b/lib/sdk/server/build.gradle @@ -71,7 +71,7 @@ ext.versions = [ "guava": "32.0.1-jre", "jackson": "2.11.2", "launchdarklyJavaSdkCommon": "2.6.0", - "launchdarklyJavaSdkInternal": "1.11.1", + "launchdarklyJavaSdkInternal": "1.13.0", "launchdarklyLogging": "1.1.0", "okhttp": "4.12.0", // specify this for the SDK build instead of relying on the transitive dependency from okhttp-eventsource "okhttpEventsource": "5.0.0", diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/DefaultEventProcessorWrapper.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/DefaultEventProcessorWrapper.java index 472c4bb6..aa25bf29 100644 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/DefaultEventProcessorWrapper.java +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/DefaultEventProcessorWrapper.java @@ -34,6 +34,15 @@ public void recordEvaluationEvent(LDContext context, String flagKey, int flagVer LDValue value, EvaluationReason reason, LDValue defaultValue, String prerequisiteOfFlagKey, boolean requireFullEvent, Long debugEventsUntilDate, boolean excludeFromSummaries, Long samplingRatio) { + recordEvaluationEvent(context, flagKey, flagVersion, variation, value, reason, defaultValue, prerequisiteOfFlagKey, + requireFullEvent, debugEventsUntilDate, excludeFromSummaries, samplingRatio, false); + } + + @Override + public void recordEvaluationEvent(LDContext context, String flagKey, int flagVersion, int variation, + LDValue value, EvaluationReason reason, LDValue defaultValue, String prerequisiteOfFlagKey, + boolean requireFullEvent, Long debugEventsUntilDate, boolean excludeFromSummaries, + Long samplingRatio, boolean overrideAffected) { eventProcessor.sendEvent(new Event.FeatureRequest( System.currentTimeMillis(), flagKey, @@ -48,7 +57,8 @@ public void recordEvaluationEvent(LDContext context, String flagKey, int flagVer debugEventsUntilDate, false, samplingRatio != null ? samplingRatio : 1, - excludeFromSummaries + excludeFromSummaries, + overrideAffected )); } diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/EvaluationRecorder.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/EvaluationRecorder.java index 8dfd1005..75a16342 100644 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/EvaluationRecorder.java +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/EvaluationRecorder.java @@ -16,7 +16,8 @@ default void recordEvaluation(DataModel.FeatureFlag flag, LDContext context, Eva default void recordPrerequisiteEvaluation(DataModel.FeatureFlag flag, DataModel.FeatureFlag prereqOfFlag, LDContext context, EvalResult result) { // default is no op } - default void recordEvaluationError(DataModel.FeatureFlag flag, LDContext context, LDValue defaultValue, EvaluationReason.ErrorKind errorKind) { + default void recordEvaluationError(DataModel.FeatureFlag flag, LDContext context, LDValue defaultValue, + EvaluationReason.ErrorKind errorKind, boolean overrideAffected) { // default is no op } default void recordEvaluationUnknownFlagError(String flagKey, LDContext context, LDValue defaultValue, EvaluationReason.ErrorKind errorKind) { 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 811af12c..f9131022 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 @@ -145,7 +145,8 @@ EvalResultAndFlag evaluate(String flagKey, LDContext context, LDValue defaultVal !value.isNull() && 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); + recorder.recordEvaluationError(featureFlag, context, defaultValue, ErrorKind.WRONG_TYPE, + result.isOverrideAffected()); // The type mismatch replaces the reason. The evaluation read the same definitions, so the // new reason keeps the override-affected marking. return new EvalResultAndFlag( @@ -164,7 +165,8 @@ EvalResultAndFlag evaluate(String flagKey, LDContext context, LDValue defaultVal if (featureFlag == null) { recorder.recordEvaluationUnknownFlagError(flagKey, context, defaultValue, ErrorKind.EXCEPTION); } else { - recorder.recordEvaluationError(featureFlag, context, defaultValue, ErrorKind.EXCEPTION); + // The evaluation did not complete, so nothing is known about which definitions it read. + recorder.recordEvaluationError(featureFlag, context, defaultValue, ErrorKind.EXCEPTION, false); } // The flag's definition was read before the failure, so its own marker decides the marking of // the error result. @@ -285,6 +287,8 @@ private static DataModel.Segment getSegment(ReadOnlyStore store, String key) { */ private static EvaluationRecorder makeEvaluationRecorder(EventProcessor processor, boolean withReasons) { return new EvaluationRecorder() { + // The marking passed to the event processor is the evaluation result's own record of the + // definitions it read. Event generation does not read the evaluation reason. @Override public void recordEvaluation(FeatureFlag flag, LDContext context, EvalResult result, LDValue defaultValue) { processor.recordEvaluationEvent( @@ -299,10 +303,13 @@ public void recordEvaluation(FeatureFlag flag, LDContext context, EvalResult res flag.isTrackEvents() || result.isForceReasonTracking(), flag.getDebugEventsUntilDate(), flag.isExcludeFromSummaries(), - flag.getSamplingRatio() + flag.getSamplingRatio(), + result.isOverrideAffected() ); } + // A prerequisite record carries the prerequisite's own marking, which its own subtree of + // reads set. It is not the marking of the evaluation that requested it. @Override public void recordPrerequisiteEvaluation(FeatureFlag flag, FeatureFlag prereqOfFlag, LDContext context, EvalResult result) { processor.recordEvaluationEvent( @@ -317,25 +324,28 @@ public void recordPrerequisiteEvaluation(FeatureFlag flag, FeatureFlag prereqOfF flag.isTrackEvents() || result.isForceReasonTracking(), flag.getDebugEventsUntilDate(), flag.isExcludeFromSummaries(), - flag.getSamplingRatio() + flag.getSamplingRatio(), + result.isOverrideAffected() ); } @Override - public void recordEvaluationError(FeatureFlag flag, LDContext context, LDValue defaultValue, ErrorKind errorKind) { + public void recordEvaluationError(FeatureFlag flag, LDContext context, LDValue defaultValue, ErrorKind errorKind, + boolean overrideAffected) { processor.recordEvaluationEvent( context, flag.getKey(), flag.getVersion(), NO_VARIATION, defaultValue, - withReasons ? EvaluationReason.error(errorKind) : null, + withReasons ? EvaluationReason.error(errorKind).withOverrideAffected(overrideAffected) : null, defaultValue, null, flag.isTrackEvents(), flag.getDebugEventsUntilDate(), flag.isExcludeFromSummaries(), - flag.getSamplingRatio() + flag.getSamplingRatio(), + overrideAffected ); } diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/NoOpEventProcessor.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/NoOpEventProcessor.java index 60684967..37b08ce4 100644 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/NoOpEventProcessor.java +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/NoOpEventProcessor.java @@ -20,6 +20,14 @@ public void recordEvaluationEvent(LDContext context, String flagKey, int flagVer // no-op } + @Override + public void recordEvaluationEvent(LDContext context, String flagKey, int flagVersion, int variation, LDValue value, + EvaluationReason reason, LDValue defaultValue, String prerequisiteOfFlagKey, + boolean requireFullEvent, Long debugEventsUntilDate, boolean excludeFromSummaries, + Long samplingRatio, boolean overrideAffected) { + // no-op + } + @Override public void recordIdentifyEvent(LDContext context) { // no-op diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/subsystems/EventProcessor.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/subsystems/EventProcessor.java index 2381b80f..3fb3a3c2 100644 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/subsystems/EventProcessor.java +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/subsystems/EventProcessor.java @@ -57,6 +57,54 @@ void recordEvaluationEvent( boolean excludeFromSummaries, Long samplingRatio ); + + /** + * Records the action of evaluating a feature flag, with the override-affected marking. + *

+ * An override-affected evaluation is one that read at least one definition from the SDK's + * override store: the flag itself, a prerequisite at any depth, or a segment consulted during + * matching. Such an evaluation produces no individual feature event and no debug event, whatever + * the flag's configuration requests, and it is counted in a separate summary counter that + * carries the marker. The SDK keys this behavior on the marking alone, not on the reason. + *

+ * The default implementation ignores the marking and calls the method without it, so an + * existing custom implementation keeps working. Flag overrides are currently experimental and + * subject to change. + * + * @param context the evaluation context + * @param flagKey key of the feature flag that was evaluated + * @param flagVersion the version of the flag, or {@link #NO_VERSION} if the flag was not found + * @param variation the result variation index, or {@link EvaluationDetail#NO_VARIATION} if evaluation failed + * @param value the result value + * @param reason the evaluation reason, or null if the reason was not requested + * @param defaultValue the default value parameter for the evaluation + * @param prerequisiteOfFlagKey the key of the flag that this flag was evaluated as a prerequisite of, + * or null if this flag was evaluated for itself + * @param requireFullEvent true if full-fidelity analytics events should be sent for this flag + * @param debugEventsUntilDate if non-null, debug events are to be generated until this millisecond time + * @param excludeFromSummaries true if the event evaluation should not be included in summaries + * @param samplingRatio ratio used to control event sampling + * @param overrideAffected true if an override affected the evaluation + * @since 7.18.0 + */ + default void recordEvaluationEvent( + LDContext context, + String flagKey, + int flagVersion, + int variation, + LDValue value, + EvaluationReason reason, + LDValue defaultValue, + String prerequisiteOfFlagKey, + boolean requireFullEvent, + Long debugEventsUntilDate, + boolean excludeFromSummaries, + Long samplingRatio, + boolean overrideAffected + ) { + recordEvaluationEvent(context, flagKey, flagVersion, variation, value, reason, defaultValue, + prerequisiteOfFlagKey, requireFullEvent, debugEventsUntilDate, excludeFromSummaries, samplingRatio); + } /** * Registers an evaluation context, as when the SDK's {@code identify} method is called. diff --git a/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/LDClientOverrideEventsTest.java b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/LDClientOverrideEventsTest.java new file mode 100644 index 00000000..a56bf9ae --- /dev/null +++ b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/LDClientOverrideEventsTest.java @@ -0,0 +1,271 @@ +package com.launchdarkly.sdk.server; + +import com.launchdarkly.logging.LDLogLevel; +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.internal.events.Event; +import com.launchdarkly.sdk.server.DataModel.FeatureFlag; +import com.launchdarkly.sdk.server.DataModel.Segment; +import com.launchdarkly.sdk.server.TestComponents.TestEventProcessor; +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.EventSender; + +import org.junit.Test; + +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; +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.initializerWith; +import static com.launchdarkly.sdk.server.TestComponents.specificComponent; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +/** + * The override-affected marking as the client hands it to the event processor, and the resulting + * analytics output: no individual feature or debug event for a marked evaluation, and a summary + * counter that carries the marker. + */ +@SuppressWarnings("javadoc") +public class LDClientOverrideEventsTest 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, int version, String value) { + return flagBuilder(key).version(version).on(false).offVariation(0).variations(LDValue.of(value)).build(); + } + + private LDConfig.Builder baseBuilder(Iterable>> ldData, + TestOverrideSource source) { + return new LDConfig.Builder() + .dataSystem(Components.dataSystem().custom().initializers(initializerWith(ldData)).overrides(source)) + .logging(Components.logging(testLogging).level(LDLogLevel.DEBUG)); + } + + private static List featureRecords(TestEventProcessor events, String flagKey) { + List result = new ArrayList<>(); + for (Event e : events.events) { + if (e instanceof Event.FeatureRequest && ((Event.FeatureRequest) e).getKey().equals(flagKey)) { + result.add((Event.FeatureRequest) e); + } + } + return result; + } + + private static Event.FeatureRequest singleRecord(TestEventProcessor events, String flagKey) { + List records = featureRecords(events, flagKey); + assertEquals("expected one record for " + flagKey, 1, records.size()); + return records.get(0); + } + + @Test + public void evaluationOfOverriddenFlagIsRecordedAsOverrideAffected() throws Exception { + TestEventProcessor events = new TestEventProcessor(); + TestOverrideSource source = new TestOverrideSource(flagsOnly(valueFlag("overridden", 5, "override-value"))); + Iterable>> ldData = flagsOnly( + valueFlag("overridden", 1, "ld-value"), valueFlag("plain", 1, "plain-value")); + + try (LDClient client = new LDClient(SDK_KEY, baseBuilder(ldData, source).events(specificComponent(events)).build())) { + client.stringVariation("overridden", CONTEXT, "default"); + client.stringVariation("plain", CONTEXT, "default"); + + Event.FeatureRequest overridden = singleRecord(events, "overridden"); + assertTrue(overridden.isOverrideAffected()); + assertEquals(5, overridden.getVersion()); + assertEquals(LDValue.of("override-value"), overridden.getValue()); + + Event.FeatureRequest plain = singleRecord(events, "plain"); + assertFalse(plain.isOverrideAffected()); + } + } + + @Test + public void recordedMarkingDoesNotDependOnRequestingTheReason() throws Exception { + TestEventProcessor events = new TestEventProcessor(); + TestOverrideSource source = new TestOverrideSource(flagsOnly(valueFlag("overridden", 5, "override-value"))); + + try (LDClient client = new LDClient(SDK_KEY, baseBuilder(flagsOnly(), source).events(specificComponent(events)).build())) { + client.stringVariation("overridden", CONTEXT, "default"); + Event.FeatureRequest record = singleRecord(events, "overridden"); + assertTrue(record.isOverrideAffected()); + assertNull(record.getReason()); + } + } + + @Test + public void prerequisiteRecordsCarryTheirOwnMarking() throws Exception { + TestEventProcessor events = new TestEventProcessor(); + FeatureFlag parent = flagBuilder("parent").version(1).on(true).fallthroughVariation(1).offVariation(0) + .variations(LDValue.of("off"), LDValue.of("on")) + .prerequisites(prerequisite("overridden-prereq", 1), prerequisite("plain-prereq", 1)).build(); + FeatureFlag ldOverriddenPrereq = flagBuilder("overridden-prereq").version(1).on(false).offVariation(0) + .variations(LDValue.of("a"), LDValue.of("b")).build(); + FeatureFlag plainPrereq = flagBuilder("plain-prereq").version(1).on(true).fallthroughVariation(1).offVariation(0) + .variations(LDValue.of("a"), LDValue.of("b")).build(); + FeatureFlag overriddenPrereq = flagBuilder("overridden-prereq").version(2).on(true).fallthroughVariation(1) + .offVariation(0).variations(LDValue.of("a"), LDValue.of("b")).build(); + TestOverrideSource source = new TestOverrideSource(flagsOnly(overriddenPrereq)); + + try (LDClient client = new LDClient(SDK_KEY, + baseBuilder(flagsOnly(parent, ldOverriddenPrereq, plainPrereq), source).events(specificComponent(events)).build())) { + assertEquals("on", client.stringVariation("parent", CONTEXT, "default")); + + assertTrue(singleRecord(events, "parent").isOverrideAffected()); + Event.FeatureRequest overriddenRecord = singleRecord(events, "overridden-prereq"); + assertTrue(overriddenRecord.isOverrideAffected()); + assertEquals("parent", overriddenRecord.getPrereqOf()); + assertEquals(2, overriddenRecord.getVersion()); + Event.FeatureRequest plainRecord = singleRecord(events, "plain-prereq"); + assertFalse(plainRecord.isOverrideAffected()); + assertEquals("parent", plainRecord.getPrereqOf()); + } + } + + @Test + public void segmentReadMarksTheRecord() throws Exception { + TestEventProcessor events = new TestEventProcessor(); + 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(); + Segment overrideSegment = segmentBuilder("seg").version(2).build(); // still does not include the context + TestOverrideSource source = new TestOverrideSource( + collections(Collections.emptyList(), Collections.singletonList(overrideSegment))); + + try (LDClient client = new LDClient(SDK_KEY, baseBuilder( + collections(Collections.singletonList(usesSegment), Collections.singletonList(ldSegment)), source) + .events(specificComponent(events)).build())) { + assertEquals("no", client.stringVariation("uses-segment", CONTEXT, "default")); + assertTrue(singleRecord(events, "uses-segment").isOverrideAffected()); + } + } + + @Test + public void typeMismatchRecordKeepsMarking() throws Exception { + TestEventProcessor events = new TestEventProcessor(); + TestOverrideSource source = new TestOverrideSource(flagsOnly(valueFlag("overridden", 5, "a string"))); + + try (LDClient client = new LDClient(SDK_KEY, baseBuilder(flagsOnly(), source).events(specificComponent(events)).build())) { + assertTrue(client.boolVariation("overridden", CONTEXT, true)); + Event.FeatureRequest record = singleRecord(events, "overridden"); + assertTrue(record.isOverrideAffected()); + assertEquals(EvaluationReason.error(ErrorKind.WRONG_TYPE).withOverrideAffected(true), + client.boolVariationDetail("overridden", CONTEXT, true).getReason()); + } + } + + @Test + public void unknownFlagRecordIsNotMarked() throws Exception { + TestEventProcessor events = new TestEventProcessor(); + TestOverrideSource source = new TestOverrideSource(flagsOnly(valueFlag("overridden", 5, "v"))); + try (LDClient client = new LDClient(SDK_KEY, baseBuilder(flagsOnly(), source).events(specificComponent(events)).build())) { + client.stringVariation("missing", CONTEXT, "default"); + assertFalse(singleRecord(events, "missing").isOverrideAffected()); + } + } + + /** + * Captures the analytics payloads that the default event processor sends. + */ + private static final class CapturingEventSender implements EventSender { + final BlockingQueue payloads = new LinkedBlockingQueue<>(); + + @Override + public Result sendAnalyticsEvents(byte[] data, int eventCount, URI eventsBaseUri) { + payloads.add(LDValue.parse(new String(data, StandardCharsets.UTF_8))); + return Result.SUCCESS; + } + + @Override + public Result sendDiagnosticEvent(byte[] data, URI eventsBaseUri) { + return Result.SUCCESS; + } + + @Override + public void close() { + } + } + + private static List eventsOfKind(LDValue payload, String kind) { + List result = new ArrayList<>(); + for (LDValue e : payload.values()) { + if (e.get("kind").stringValue().equals(kind)) { + result.add(e); + } + } + return result; + } + + @Test + public void overrideAffectedEvaluationsAppearOnlyInSummaryOutput() throws Exception { + long debugUntil = System.currentTimeMillis() + 100000; + // Both flags request individual feature events and debug events. + FeatureFlag overridden = flagBuilder("overridden").version(300).on(false).offVariation(0) + .variations(LDValue.of("override-value")).trackEvents(true).debugEventsUntilDate(debugUntil).build(); + FeatureFlag plain = flagBuilder("plain").version(100).on(false).offVariation(0) + .variations(LDValue.of("plain-value")).trackEvents(true).debugEventsUntilDate(debugUntil).build(); + TestOverrideSource source = new TestOverrideSource(flagsOnly(overridden)); + CapturingEventSender sender = new CapturingEventSender(); + + LDConfig config = baseBuilder(flagsOnly(plain), source) + .events(Components.sendEvents().eventSender(specificComponent(sender)).flushInterval(Duration.ofHours(1))) + .diagnosticOptOut(true) + .build(); + + try (LDClient client = new LDClient(SDK_KEY, config)) { + client.stringVariation("overridden", CONTEXT, "default"); + client.stringVariation("overridden", CONTEXT, "default"); + client.stringVariation("plain", CONTEXT, "default"); + client.flush(); + + LDValue payload = sender.payloads.poll(5, TimeUnit.SECONDS); + assertNotNull("no analytics payload was sent", payload); + + // The overridden flag produced no feature event and no debug event. The plain flag produced both. + List featureEvents = eventsOfKind(payload, "feature"); + List debugEvents = eventsOfKind(payload, "debug"); + assertEquals(1, featureEvents.size()); + assertEquals("plain", featureEvents.get(0).get("key").stringValue()); + assertEquals(1, debugEvents.size()); + assertEquals("plain", debugEvents.get(0).get("key").stringValue()); + assertEquals(1, eventsOfKind(payload, "index").size()); + + // Both flags are summarized. Only the overridden flag's counter carries the marker. + List summaries = eventsOfKind(payload, "summary"); + assertEquals(1, summaries.size()); + LDValue features = summaries.get(0).get("features"); + LDValue overriddenCounters = features.get("overridden").get("counters"); + assertEquals(1, overriddenCounters.size()); + LDValue overriddenCounter = overriddenCounters.get(0); + assertTrue(overriddenCounter.get("overrideAffected").booleanValue()); + assertEquals(300, overriddenCounter.get("version").intValue()); + assertEquals(2, overriddenCounter.get("count").intValue()); + assertEquals(LDValue.of("override-value"), overriddenCounter.get("value")); + LDValue plainCounter = features.get("plain").get("counters").get(0); + assertTrue(plainCounter.get("overrideAffected").isNull()); + assertEquals(1, plainCounter.get("count").intValue()); + } + } +} 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 index d211da32..57d7f887 100644 --- 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 @@ -4,7 +4,9 @@ import com.launchdarkly.sdk.EvaluationDetail; import com.launchdarkly.sdk.LDContext; import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.internal.events.Event; import com.launchdarkly.sdk.json.JsonSerialization; +import com.launchdarkly.sdk.server.TestComponents.TestEventProcessor; import com.launchdarkly.sdk.server.integrations.DataSystemBuilder; import com.launchdarkly.sdk.server.subsystems.DataStoreTypes.DataKind; import com.launchdarkly.sdk.server.subsystems.DataStoreTypes.ItemDescriptor; @@ -25,6 +27,7 @@ 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 com.launchdarkly.sdk.server.TestComponents.specificComponent; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; @@ -32,7 +35,8 @@ /** * 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. + * and checks the value, the variation index, the reason, and the marking that the client hands to the + * event processor for the evaluation. */ @SuppressWarnings("javadoc") public class OverrideSpecVectorsTest extends BaseTest { @@ -106,8 +110,9 @@ private void runVector(LDValue vector) throws Exception { overrides.get("flags"), overrides.get("flagValues"), overrides.get("segments"))); DataSystemBuilder dataSystem = Components.dataSystem().custom().overrides(source); + TestEventProcessor events = new TestEventProcessor(); LDConfig.Builder config = new LDConfig.Builder() - .events(Components.noEvents()) + .events(specificComponent(events)) .logging(Components.logging(testLogging).level(LDLogLevel.DEBUG)); if (initialized) { dataSystem.initializers(initializerWith(collectionsFromJson( @@ -137,6 +142,22 @@ private void runVector(LDValue vector) throws Exception { assertEquals("variationIndex", expect.get("variationIndex").intValue(), detail.getVariationIndex()); } assertVectorReason(expect.get("reason"), LDValue.parse(JsonSerialization.serialize(detail.getReason()))); + + // summaryOverrideAffected is the marking the client hands to the event processor for this + // evaluation. The event processor keys individual event suppression and the summary counter + // marker on that value, not on the reason. + if (!expect.get("summaryOverrideAffected").isNull()) { + List records = new ArrayList<>(); + for (Event e : events.events) { + if (e instanceof Event.FeatureRequest + && ((Event.FeatureRequest) e).getKey().equals(evaluate.get("flagKey").stringValue())) { + records.add((Event.FeatureRequest) e); + } + } + assertEquals("expected exactly one evaluation record for the flag", 1, records.size()); + assertEquals("summaryOverrideAffected", expect.get("summaryOverrideAffected").booleanValue(), + records.get(0).isOverrideAffected()); + } } } diff --git a/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/TestComponents.java b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/TestComponents.java index 77b4d699..5bc8d51a 100644 --- a/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/TestComponents.java +++ b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/TestComponents.java @@ -149,8 +149,17 @@ public void recordEvaluationEvent(LDContext context, String flagKey, int flagVer EvaluationReason reason, LDValue defaultValue, String prerequisiteOfFlagKey, boolean requireFullEvent, Long debugEventsUntilDate, boolean excludeFromSummaries, Long samplingRatio) { + recordEvaluationEvent(context, flagKey, flagVersion, variation, value, reason, defaultValue, prerequisiteOfFlagKey, + requireFullEvent, debugEventsUntilDate, excludeFromSummaries, samplingRatio, false); + } + + @Override + public void recordEvaluationEvent(LDContext context, String flagKey, int flagVersion, int variation, LDValue value, + EvaluationReason reason, LDValue defaultValue, String prerequisiteOfFlagKey, boolean requireFullEvent, + Long debugEventsUntilDate, boolean excludeFromSummaries, Long samplingRatio, boolean overrideAffected) { events.add(new Event.FeatureRequest(System.currentTimeMillis(), flagKey, context, flagVersion, - variation, value, defaultValue, reason, prerequisiteOfFlagKey, requireFullEvent, debugEventsUntilDate, false)); + variation, value, defaultValue, reason, prerequisiteOfFlagKey, requireFullEvent, debugEventsUntilDate, false, + samplingRatio == null ? 1 : samplingRatio, excludeFromSummaries, overrideAffected)); } @Override From 0a91418aa17191397d62fa850139edce38678291 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Thu, 29 Oct 2026 16:56:31 +0000 Subject: [PATCH 14/30] fix: Mark the error event of an overridden flag whose evaluation throws The error event recorded when an evaluation throws now carries the marking of the flag's own definition, which was read before the failure, the same marking as the error result. A marked error event produces no individual event and is counted under the marked summary counter. --- .../sdk/server/InputValidatingEvaluator.java | 9 ++++----- .../InputValidatingEvaluatorOverrideTest.java | 17 +++++++++++++++++ 2 files changed, 21 insertions(+), 5 deletions(-) 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 f9131022..59a8dde7 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 @@ -162,15 +162,14 @@ EvalResultAndFlag evaluate(String flagKey, LDContext context, LDValue defaultVal logger.error("Encountered exception while evaluating feature flag \"{}\": {}", flagKey, LogValues.exceptionSummary(e)); logger.debug("{}", LogValues.exceptionTrace(e)); + // The flag's definition was read before the failure, so its own marker decides the marking of + // the error event and of the error result. + boolean overrideAffected = featureFlag != null && featureFlag.isOverride(); if (featureFlag == null) { recorder.recordEvaluationUnknownFlagError(flagKey, context, defaultValue, ErrorKind.EXCEPTION); } else { - // The evaluation did not complete, so nothing is known about which definitions it read. - recorder.recordEvaluationError(featureFlag, context, defaultValue, ErrorKind.EXCEPTION, false); + recorder.recordEvaluationError(featureFlag, context, defaultValue, ErrorKind.EXCEPTION, overrideAffected); } - // The flag's definition was read before the failure, so its own marker decides the marking of - // the error result. - boolean overrideAffected = featureFlag != null && featureFlag.isOverride(); return new EvalResultAndFlag( EvalResult.of(defaultValue, NO_VARIATION, EvaluationReason.exception(e)).withOverrideAffected(overrideAffected), null); 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 8ffdd167..b8442ee3 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 @@ -4,8 +4,10 @@ import com.launchdarkly.sdk.EvaluationReason.ErrorKind; import com.launchdarkly.sdk.LDValue; import com.launchdarkly.sdk.LDValueType; +import com.launchdarkly.sdk.internal.events.Event; import com.launchdarkly.sdk.json.JsonSerialization; import com.launchdarkly.sdk.server.DataModel.FeatureFlag; +import com.launchdarkly.sdk.server.TestComponents.TestEventProcessor; import com.launchdarkly.sdk.server.subsystems.DataStoreTypes.DataKind; import com.launchdarkly.sdk.server.subsystems.DataStoreTypes.ItemDescriptor; import com.launchdarkly.sdk.server.subsystems.DataStoreTypes.KeyedItems; @@ -94,6 +96,21 @@ public void exceptionWhileEvaluatingOverrideFlagKeepsMarking() { assertTrue(result.getResult().isOverrideAffected()); } + @Test + public void exceptionWhileEvaluatingOverrideFlagRecordsAMarkedErrorEvent() { + TestEventProcessor events = new TestEventProcessor(); + FeatureFlag flag = flagWithPrerequisite("flag").markedAsOverride(); + new InputValidatingEvaluator(storeFailingOn("prereq", flag), null, null, events, nullLogger) + .evalAndFlag("LDClient.stringVariation", "flag", BASE_USER, LDValue.of("default"), LDValueType.STRING, + EvaluationOptions.EVENTS_WITH_REASONS); + + assertEquals(1, events.events.size()); + Event.FeatureRequest record = (Event.FeatureRequest) events.events.get(0); + assertTrue(record.isOverrideAffected()); + assertEquals(EvaluationReason.ErrorKind.EXCEPTION, record.getReason().getErrorKind()); + assertTrue(record.getReason().isOverrideAffected()); + } + @Test public void exceptionWhileEvaluatingPlainFlagIsNotMarked() { FeatureFlag flag = flagWithPrerequisite("flag"); From 90bc6038c86205c65bb8fb353b1b48cc2c2bcb82 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Fri, 25 Sep 2026 16:07:01 -0700 Subject: [PATCH 15/30] feat: Add the file-based override source The OVERRIDE specification defines a file-based override source that reads override entries from one or more local files and reloads them when the files change. FileOverrides.source() provides it, configured through DataSystemBuilder.overrides(...). The source accepts the file data source document format (flags, flagValues, segments) in JSON or YAML, merges the files in the configured order with the configured duplicate keys handling (fail by default, or ignore all but the first), and keeps the versions that the documents specify. A flagValues entry becomes a flag that is off and serves the value as its single variation. Change detection is polling (the default, one second by default and at minimum) or watching. A configured file that does not exist contributes no overrides. A file that exists but cannot be read or parsed fails that reload, keeps the last good overrides, logs the failure, and is retried. The initial load completes during client construction. Every applied change is logged at Info with the overrides in effect and what each file supplied. Missing file paths, a null change detection mode, or a null poll interval fail client construction. Flag overrides are currently experimental and subject to change. --- .../integrations/DataSystemBuilder.java | 1 + .../FileOverrideSourceBuilder.java | 147 ++++++ .../integrations/FileOverrideSourceImpl.java | 169 +++++++ .../server/integrations/FileOverrides.java | 106 ++++ .../sdk/server/subsystems/OverrideSource.java | 3 +- .../integrations/FileOverrideSourceTest.java | 475 ++++++++++++++++++ 6 files changed, 900 insertions(+), 1 deletion(-) create mode 100644 lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FileOverrideSourceBuilder.java create mode 100644 lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FileOverrideSourceImpl.java create mode 100644 lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FileOverrides.java create mode 100644 lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/integrations/FileOverrideSourceTest.java 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 72ad5097..fdaa3093 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 @@ -138,6 +138,7 @@ public DataSystemBuilder persistentStore(ComponentConfigurer persiste * * @param overrideSource the override source configuration, or null for none * @return a reference to the builder + * @see FileOverrides * @since 7.18.0 */ public DataSystemBuilder overrides(ComponentConfigurer overrideSource) { diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FileOverrideSourceBuilder.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FileOverrideSourceBuilder.java new file mode 100644 index 00000000..ea19763e --- /dev/null +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FileOverrideSourceBuilder.java @@ -0,0 +1,147 @@ +package com.launchdarkly.sdk.server.integrations; + +import com.launchdarkly.logging.LDLogger; +import com.launchdarkly.sdk.server.integrations.FileOverrides.ChangeDetection; +import com.launchdarkly.sdk.server.subsystems.ClientContext; +import com.launchdarkly.sdk.server.subsystems.ComponentConfigurer; +import com.launchdarkly.sdk.server.subsystems.OverrideSource; + +import java.nio.file.InvalidPathException; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; + +/** + * Configures the file-based override source. Obtain an instance with {@link FileOverrides#source()}, + * call {@link #filePaths(String...)} or {@link #filePaths(Path...)} to specify the files, adjust + * any other options, and pass the builder to + * {@link DataSystemBuilder#overrides(ComponentConfigurer)}. + *

+ * For more details, see {@link FileOverrides}. + *

+ * Flag overrides are currently experimental and subject to change. + * + * @since 7.18.0 + */ +public final class FileOverrideSourceBuilder implements ComponentConfigurer { + private final List filePaths = new ArrayList<>(); + private FileData.DuplicateKeysHandling duplicateKeysHandling = FileData.DuplicateKeysHandling.FAIL; + private ChangeDetection changeDetection = ChangeDetection.POLLING; + private Duration pollInterval = FileOverrides.DEFAULT_POLL_INTERVAL; + + FileOverrideSourceBuilder() {} + + /** + * Adds any number of files to load overrides from, specifying each file path as a string. The + * order is significant: it determines which file wins under the duplicate keys handling when the + * same key appears in more than one file. + *

+ * Files are parsed as JSON if their first non-whitespace character is '{'. Otherwise, they are + * parsed as YAML. + * + * @param filePaths path(s) to the file(s); may be absolute or relative to the current working directory + * @return the same builder + * @throws InvalidPathException if one of the parameters is not a valid file path + */ + public FileOverrideSourceBuilder filePaths(String... filePaths) throws InvalidPathException { + for (String p : filePaths) { + this.filePaths.add(Paths.get(p)); + } + return this; + } + + /** + * Adds any number of files to load overrides from, specifying each file path as a Path. The + * order is significant: it determines which file wins under the duplicate keys handling when the + * same key appears in more than one file. + * + * @param filePaths path(s) to the file(s); may be absolute or relative to the current working directory + * @return the same builder + */ + public FileOverrideSourceBuilder filePaths(Path... filePaths) { + for (Path p : filePaths) { + this.filePaths.add(p); + } + return this; + } + + /** + * Specifies how to handle the same key appearing in more than one file. + *

+ * With {@link FileData.DuplicateKeysHandling#FAIL}, the default, a reload fails if the same flag or + * segment key appears in more than one file, and the previously loaded overrides stay in effect. + * With {@link FileData.DuplicateKeysHandling#IGNORE}, the entry from the first configured file that + * defines the key is kept and the others are discarded. A null value selects the default. + * + * @param duplicateKeysHandling specifies how to handle duplicate keys + * @return the same builder + */ + public FileOverrideSourceBuilder duplicateKeysHandling(FileData.DuplicateKeysHandling duplicateKeysHandling) { + this.duplicateKeysHandling = duplicateKeysHandling == null + ? FileData.DuplicateKeysHandling.FAIL : duplicateKeysHandling; + return this; + } + + /** + * Selects how the source detects file changes. The default is {@link ChangeDetection#POLLING}. + * The two modes are alternatives, so setting one replaces the other. + * + * @param changeDetection the change detection mode + * @return the same builder + */ + public FileOverrideSourceBuilder changeDetection(ChangeDetection changeDetection) { + this.changeDetection = changeDetection; + return this; + } + + /** + * Sets the interval between examinations of the files in {@link ChangeDetection#POLLING} mode. + * {@link ChangeDetection#WATCHING} mode ignores it. The default is + * {@link FileOverrides#DEFAULT_POLL_INTERVAL}. An interval below + * {@link FileOverrides#MINIMUM_POLL_INTERVAL} is raised to the minimum. + * + * @param pollInterval the polling interval + * @return the same builder + */ + public FileOverrideSourceBuilder pollInterval(Duration pollInterval) { + this.pollInterval = pollInterval; + return this; + } + + /** + * Called internally by the SDK to create the override source. + * + * @param context the client context + * @return the override source + * @throws IllegalArgumentException if no file paths were specified, or the change detection mode + * or the poll interval is null + */ + @Override + public OverrideSource build(ClientContext context) { + if (filePaths.isEmpty()) { + throw new IllegalArgumentException("no file paths were specified for the file-based override source"); + } + if (changeDetection == null) { + throw new IllegalArgumentException("a change detection mode is required for the file-based override source"); + } + if (pollInterval == null) { + throw new IllegalArgumentException("a poll interval is required for the file-based override source"); + } + List absolutePaths = new ArrayList<>(filePaths.size()); + for (Path p : filePaths) { + absolutePaths.add(p.toAbsolutePath().normalize()); + } + + LDLogger logger = context.getBaseLogger().subLogger("FileOverrideSource"); + + Duration effectivePollInterval = pollInterval; + if (changeDetection == ChangeDetection.POLLING && pollInterval.compareTo(FileOverrides.MINIMUM_POLL_INTERVAL) < 0) { + logger.warn("Poll interval {} is below the minimum; using {}", pollInterval, FileOverrides.MINIMUM_POLL_INTERVAL); + effectivePollInterval = FileOverrides.MINIMUM_POLL_INTERVAL; + } + + return new FileOverrideSourceImpl(absolutePaths, duplicateKeysHandling, changeDetection, effectivePollInterval, logger); + } +} diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FileOverrideSourceImpl.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FileOverrideSourceImpl.java new file mode 100644 index 00000000..22d719c4 --- /dev/null +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FileOverrideSourceImpl.java @@ -0,0 +1,169 @@ +package com.launchdarkly.sdk.server.integrations; + +import com.launchdarkly.logging.LDLogger; +import com.launchdarkly.sdk.server.integrations.FileDataSourceParsing.FileDataException; +import com.launchdarkly.sdk.server.integrations.FileOverrides.ChangeDetection; +import com.launchdarkly.sdk.server.integrations.OverrideFileLoader.FileSummary; +import com.launchdarkly.sdk.server.integrations.OverrideFileLoader.LoadResult; +import com.launchdarkly.sdk.server.subsystems.OverrideSink; +import com.launchdarkly.sdk.server.subsystems.OverrideSource; + +import java.io.IOException; +import java.nio.file.Path; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.atomic.AtomicBoolean; + +/** + * The file-based override source. It reads one or more files in the file data source document + * format, merges them in the configured order, and supplies the result to the override sink as + * one snapshot. It reloads when the files change, by polling or by watching. + *

+ * A configured file that does not exist contributes no overrides. A file that exists but cannot + * be read or parsed fails that reload: the sink is not called, so the last good overrides stay in + * effect, and the reload is retried. + */ +final class FileOverrideSourceImpl implements OverrideSource { + private final List paths; + private final ChangeDetection changeDetection; + private final Duration pollInterval; + private final LDLogger logger; + private final OverrideFileLoader loader; + + private volatile FileDataReloader reloader; + private volatile FileDataPoller poller; + private volatile FileDataWatcher watcher; + private final AtomicBoolean closed = new AtomicBoolean(false); + + FileOverrideSourceImpl( + List paths, + FileData.DuplicateKeysHandling duplicateKeysHandling, + ChangeDetection changeDetection, + Duration pollInterval, + LDLogger logger + ) { + this.paths = new ArrayList<>(paths); + this.changeDetection = changeDetection; + this.pollInterval = pollInterval; + this.logger = logger; + this.loader = new OverrideFileLoader(paths, duplicateKeysHandling); + } + + /** + * Performs the initial load synchronously, so overrides present in the files are in effect when + * the client constructor returns, then starts change detection. A file that does not exist yet + * contributes no overrides. A file that cannot be read or parsed is not fatal: the client runs + * with the last good overrides, the failure is logged, and the retry, plus the change signal, + * recovers once the file is readable. + */ + @Override + public void start(OverrideSink sink) { + reloader = new FileDataReloader( + loader::load, + new FileDataReloader.Handler() { + @Override + public void apply(LoadResult result) { + sink.setOverrides(result.getData()); + logOverridesInEffect(result); + } + + @Override + public void onError(FileDataException e) { + // The reloader has logged the failure. The last good overrides stay in effect. + } + }, + logger, + FileDataReloader.DEFAULT_DEBOUNCE_DELAY, + FileDataReloader.DEFAULT_RETRY_DELAY, + true + ); + reloader.reloadNow(); + + if (closed.get()) { + return; + } + switch (changeDetection) { + case WATCHING: + try { + FileDataWatcher w = FileDataWatcher.create(paths, logger); + watcher = w; + w.start(reloader::trigger); + } catch (IOException e) { + // COVERAGE: constructing a watcher only fails under unusual OS conditions + logger.error("Unable to watch override files: {}", e.toString()); + } + break; + case POLLING: + default: + poller = new FileDataPoller(paths, pollInterval, reloader::trigger); + break; + } + } + + /** + * Returns the interval between file examinations in polling mode. Visible for tests. + * + * @return the effective poll interval + */ + Duration getPollInterval() { + return pollInterval; + } + + // Reports the overrides now in effect and the file each came from. The reloader applies a + // snapshot only when the content changed, so this logs each change once. + private void logOverridesInEffect(LoadResult result) { + List details = new ArrayList<>(result.getFiles().size()); + for (FileSummary file : result.getFiles()) { + if (!file.isPresent()) { + details.add(file.getPath() + ": absent"); + } else if (file.getFlags() == 0 && file.getSegments() == 0) { + details.add(file.getPath() + ": no entries"); + } else { + details.add(file.getPath() + ": " + countsText(file.getFlags(), file.getSegments())); + } + } + String fileDetails = String.join("; ", details); + if (result.getFlagCount() == 0 && result.getSegmentCount() == 0) { + logger.info("Flag overrides: none in effect ({})", fileDetails); + return; + } + logger.info("Flag overrides in effect: {} ({})", countsText(result.getFlagCount(), result.getSegmentCount()), + fileDetails); + } + + // Formats flag and segment counts, for example "2 flags, 1 segment". + static String countsText(int flags, int segments) { + List parts = new ArrayList<>(2); + if (flags > 0) { + parts.add(pluralize(flags, "flag")); + } + if (segments > 0) { + parts.add(pluralize(segments, "segment")); + } + return String.join(", ", parts); + } + + private static String pluralize(int count, String noun) { + return count == 1 ? "1 " + noun : count + " " + noun + "s"; + } + + @Override + public void close() { + if (closed.getAndSet(true)) { + return; + } + FileDataWatcher w = watcher; + if (w != null) { + w.close(); + } + FileDataPoller p = poller; + if (p != null) { + p.close(); + } + FileDataReloader r = reloader; + if (r != null) { + r.close(); + } + } +} diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FileOverrides.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FileOverrides.java new file mode 100644 index 00000000..34342505 --- /dev/null +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FileOverrides.java @@ -0,0 +1,106 @@ +package com.launchdarkly.sdk.server.integrations; + +import java.time.Duration; + +/** + * Integration between the LaunchDarkly SDK and file-based flag overrides. + *

+ * Flag overrides are currently experimental and subject to change. + *

+ * Overrides are flag and segment definitions that take precedence over data received from + * LaunchDarkly at evaluation time, on a per-key basis. They exist for resilience during an + * incident. An operator can force one or more flags to a known state on a running application, + * whether or not the application can reach LaunchDarkly. The override stays in effect until the + * operator removes it. Flags not present in the override data are completely unaffected. + *

+ * This class provides one override source: {@link #source()}, which reads overrides from local + * files and reloads them as the files change. Configure it with the data system builder: + *


+ *     LDConfig config = new LDConfig.Builder()
+ *         .dataSystem(Components.dataSystem().defaultMode()
+ *             .overrides(FileOverrides.source().filePaths("/etc/launchdarkly/overrides.json")))
+ *         .build();
+ * 
+ *

+ * An evaluation that an override affects is marked. The marking is direct or transitive: it + * applies when the evaluated flag, a prerequisite at any depth, or a segment read during the + * evaluation came from the override layer. {@link com.launchdarkly.sdk.EvaluationReason#isOverrideAffected()} + * reports the marking. Marked evaluations appear in analytics summary events only, under separate + * counters, so LaunchDarkly can distinguish them. They produce no individual evaluation events. + * + * @see FileOverrideSourceBuilder + * @see DataSystemBuilder#overrides(com.launchdarkly.sdk.server.subsystems.ComponentConfigurer) + * @since 7.18.0 + */ +public abstract class FileOverrides { + /** + * Selects how the file-based override source learns that a file changed. The two modes are + * alternatives. Flag overrides are currently experimental and subject to change. + * + * @see FileOverrideSourceBuilder#changeDetection(ChangeDetection) + */ + public enum ChangeDetection { + /** + * The source examines the files on a fixed interval and reloads when the modification time or + * the size of a file changes. Polling works on every file system, including network mounts and + * directories whose contents are swapped through symbolic links, as Kubernetes does for mounted + * ConfigMaps. It is the default. + */ + POLLING, + + /** + * The source reloads in response to file system change notifications. It reacts faster than + * polling. It depends on notifications, which some file systems do not deliver reliably. + */ + WATCHING + } + + /** + * The interval at which the file source examines the files for changes in + * {@link ChangeDetection#POLLING} mode when no interval was specified. Because the source reads + * local files rather than contacting a service, a short interval keeps an override responsive + * during an incident at negligible cost. + */ + public static final Duration DEFAULT_POLL_INTERVAL = Duration.ofSeconds(1); + + /** + * The shortest allowed polling interval. A configured interval below this is raised to it. The + * minimum exists only to prevent a pathological tight loop over the file system. + */ + public static final Duration MINIMUM_POLL_INTERVAL = Duration.ofSeconds(1); + + private FileOverrides() {} + + /** + * Creates a {@link FileOverrideSourceBuilder} for a file-based override source. The source reads + * flag and segment overrides from one or more local files and reloads them as the files change. + *

+ * The files use the same document format as the file data source (see {@link FileData}): each + * file is a JSON or YAML document with optional {@code "flags"}, {@code "flagValues"}, and + * {@code "segments"} members. A {@code "flagValues"} entry is expanded into a full flag + * definition that is off and serves the given value as its single variation for every context. + * When multiple files are configured, their entries are combined in the configured order, and + * the duplicate keys handling decides which file wins for a key that appears more than once. + *

+ * A reload replaces the entire override set, so removing an entry from the files removes the + * override. A configured file that does not exist contributes no overrides. Deleting a file + * therefore removes its overrides, and deleting every file removes them all. A file that exists + * but cannot be read or parsed makes that whole reload fail: the previously loaded overrides + * stay in effect, the source logs the failure, retries after a short delay, and recovers on its + * own once the file is readable again. + *

+ * Whenever the set of overrides in effect changes, including at startup, the source logs the + * overrides in effect and what each configured file supplied, at Info level. + *

+ * By default the source polls the files for changes once per second. See + * {@link FileOverrideSourceBuilder#changeDetection(ChangeDetection)} and + * {@link FileOverrideSourceBuilder#pollInterval(Duration)}. + *

+ * Flag overrides are currently experimental and subject to change. + * + * @return a builder for the file-based override source + */ + public static FileOverrideSourceBuilder source() { + return new FileOverrideSourceBuilder(); + } +} 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 index dbaf0452..ba55d2b7 100644 --- 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 @@ -16,7 +16,8 @@ * {@link com.launchdarkly.sdk.server.integrations.DataSystemBuilder#overrides(ComponentConfigurer)}. * The SDK closes the source when the client is closed. An exception thrown from {@link #close()} is * logged and does not stop the client from shutting down. A snapshot that the source supplies after - * the client begins to close it is ignored. + * the client begins to close it is ignored. The SDK provides a file-based source; see + * {@link com.launchdarkly.sdk.server.integrations.FileOverrides}. *

* Flag overrides are currently experimental and subject to change. * diff --git a/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/integrations/FileOverrideSourceTest.java b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/integrations/FileOverrideSourceTest.java new file mode 100644 index 00000000..51d1f942 --- /dev/null +++ b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/integrations/FileOverrideSourceTest.java @@ -0,0 +1,475 @@ +package com.launchdarkly.sdk.server.integrations; + +import com.launchdarkly.logging.LDLogLevel; +import com.launchdarkly.logging.LogCapture; +import com.launchdarkly.logging.Logs; +import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.server.Components; +import com.launchdarkly.sdk.server.LDConfig; +import com.launchdarkly.sdk.server.TestComponents; +import com.launchdarkly.sdk.server.integrations.FileOverrides.ChangeDetection; +import com.launchdarkly.sdk.server.subsystems.ClientContext; +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 com.launchdarkly.testhelpers.TempDir; + +import org.junit.After; +import org.junit.Test; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.TimeUnit; + +import static com.launchdarkly.sdk.server.DataModel.FEATURES; +import static com.launchdarkly.sdk.server.DataModel.SEGMENTS; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.allOf; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.hasItem; +import static org.hamcrest.Matchers.startsWith; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +@SuppressWarnings("javadoc") +public class FileOverrideSourceTest { + private static final long WAIT_MILLIS = 10000; + + private final LogCapture logCapture = Logs.capture(); + private final ClientContext context = TestComponents.clientContext("sdk-key", + new LDConfig.Builder().logging(Components.logging(logCapture).level(LDLogLevel.DEBUG)).build()); + private final List sources = new ArrayList<>(); + + @After + public void closeSources() throws Exception { + for (OverrideSource s : sources) { + s.close(); + } + } + + /** + * Records every snapshot the source supplies, as a map of kind name to key to item. + */ + static final class RecordingSink implements OverrideSink { + final BlockingQueue>> snapshots = new LinkedBlockingQueue<>(); + + @Override + public void setOverrides(Iterable>> data) { + Map> snapshot = new HashMap<>(); + snapshot.put(FEATURES.getName(), new HashMap<>()); + snapshot.put(SEGMENTS.getName(), new HashMap<>()); + for (Map.Entry> kind : data) { + Map items = snapshot.computeIfAbsent(kind.getKey().getName(), k -> new HashMap<>()); + for (Map.Entry item : kind.getValue().getItems()) { + items.put(item.getKey(), item.getValue()); + } + } + snapshots.add(snapshot); + } + + Map> await() throws InterruptedException { + Map> s = snapshots.poll(WAIT_MILLIS, TimeUnit.MILLISECONDS); + if (s == null) { + throw new AssertionError("timed out waiting for an override snapshot"); + } + return s; + } + + void assertNoSnapshot(long millis) throws InterruptedException { + assertNull(snapshots.poll(millis, TimeUnit.MILLISECONDS)); + } + } + + private OverrideSource start(FileOverrideSourceBuilder builder, RecordingSink sink) { + OverrideSource source = builder.build(context); + sources.add(source); + source.start(sink); + return source; + } + + private static String flagValue(Map> snapshot, String key) { + ItemDescriptor item = snapshot.get(FEATURES.getName()).get(key); + if (item == null) { + return null; + } + LDValue json = LDValue.parse(FEATURES.serialize(item)); + return json.get("variations").get(0).stringValue(); + } + + private static Path write(TempDir dir, String name, String contents) throws Exception { + Path p = dir.getPath().resolve(name); + Files.write(p, contents.getBytes()); + return p; + } + + private static String docWith(String key, String value) { + return "{\"flagValues\":{\"" + key + "\":\"" + value + "\"}}"; + } + + @Test + public void buildRequiresFilePaths() { + try { + FileOverrides.source().build(context); + fail("expected exception"); + } catch (IllegalArgumentException e) { + assertThat(e.getMessage(), containsString("no file paths")); + } + } + + @Test + public void buildRequiresChangeDetectionMode() { + try { + FileOverrides.source().filePaths("x.json").changeDetection(null).build(context); + fail("expected exception"); + } catch (IllegalArgumentException e) { + assertThat(e.getMessage(), containsString("change detection")); + } + } + + @Test + public void buildRequiresPollInterval() { + try { + FileOverrides.source().filePaths("x.json").pollInterval(null).build(context); + fail("expected exception"); + } catch (IllegalArgumentException e) { + assertThat(e.getMessage(), containsString("poll interval")); + } + } + + @Test + public void nullDuplicateKeysHandlingSelectsFail() throws Exception { + try (TempDir dir = TempDir.create()) { + Path a = write(dir, "a.json", docWith("flag", "a")); + Path b = write(dir, "b.json", docWith("flag", "b")); + RecordingSink sink = new RecordingSink(); + start(FileOverrides.source().filePaths(a, b).duplicateKeysHandling(null), sink); + sink.assertNoSnapshot(200); + assertThat(logCapture.getMessageStrings(), hasItem(allOf(startsWith("ERROR:Unable to load flags: "), + containsString("in features, key \"flag\" was already defined"), containsString(b.toString())))); + } + } + + @Test + public void initialLoadCompletesBeforeStartReturns() throws Exception { + try (TempDir dir = TempDir.create()) { + Path file = write(dir, "overrides.json", + "{\"flags\":{\"full\":{\"key\":\"full\",\"version\":7,\"on\":true,\"variations\":[\"x\"],\"fallthrough\":{\"variation\":0}}}," + + "\"flagValues\":{\"simple\":\"value\"}," + + "\"segments\":{\"seg\":{\"key\":\"seg\",\"version\":3,\"included\":[\"u\"]}}}"); + RecordingSink sink = new RecordingSink(); + start(FileOverrides.source().filePaths(file), sink); + + Map> snapshot = sink.snapshots.poll(); + assertNotNull("the initial snapshot must be supplied before start returns", snapshot); + assertEquals(7, snapshot.get(FEATURES.getName()).get("full").getVersion()); + assertEquals("value", flagValue(snapshot, "simple")); + // A value-only entry is an off flag with version 0. + ItemDescriptor simple = snapshot.get(FEATURES.getName()).get("simple"); + assertEquals(0, simple.getVersion()); + LDValue simpleJson = LDValue.parse(FEATURES.serialize(simple)); + assertEquals(LDValue.of(false), simpleJson.get("on")); + assertEquals(LDValue.of(0), simpleJson.get("offVariation")); + assertEquals(3, snapshot.get(SEGMENTS.getName()).get("seg").getVersion()); + assertThat(logCapture.getMessageStrings(), hasItem( + "INFO:Flag overrides in effect: 2 flags, 1 segment (" + file + ": 2 flags, 1 segment)")); + } + } + + @Test + public void loadsYamlDocument() throws Exception { + try (TempDir dir = TempDir.create()) { + Path file = write(dir, "overrides.yaml", "flagValues:\n yaml-flag: \"override-value\"\n"); + RecordingSink sink = new RecordingSink(); + start(FileOverrides.source().filePaths(file), sink); + assertEquals("override-value", flagValue(sink.await(), "yaml-flag")); + } + } + + @Test + public void mergesFilesInConfiguredOrderWithIgnoreHandling() throws Exception { + try (TempDir dir = TempDir.create()) { + Path first = write(dir, "first.json", docWith("shared", "first")); + Path second = write(dir, "second.json", "{\"flagValues\":{\"shared\":\"second\",\"only-second\":\"x\"}}"); + RecordingSink sink = new RecordingSink(); + start(FileOverrides.source().filePaths(first, second) + .duplicateKeysHandling(FileData.DuplicateKeysHandling.IGNORE), sink); + + Map> snapshot = sink.await(); + assertEquals("first", flagValue(snapshot, "shared")); + assertEquals("x", flagValue(snapshot, "only-second")); + assertThat(logCapture.getMessageStrings(), hasItem( + "INFO:Flag overrides in effect: 2 flags (" + first + ": 1 flag; " + second + ": 1 flag)")); + } + } + + @Test + public void duplicateKeysFailTheLoadByDefault() throws Exception { + try (TempDir dir = TempDir.create()) { + Path a = write(dir, "a.json", docWith("flag", "a")); + Path b = write(dir, "b.json", docWith("flag", "b")); + RecordingSink sink = new RecordingSink(); + start(FileOverrides.source().filePaths(a, b), sink); + sink.assertNoSnapshot(200); + } + } + + @Test + public void missingFileContributesNoEntriesAndIsNotAnError() throws Exception { + try (TempDir dir = TempDir.create()) { + Path present = write(dir, "present.json", docWith("flag", "value")); + Path missing = dir.getPath().resolve("missing.json"); + RecordingSink sink = new RecordingSink(); + start(FileOverrides.source().filePaths(present, missing), sink); + + Map> snapshot = sink.await(); + assertEquals("value", flagValue(snapshot, "flag")); + assertEquals(1, snapshot.get(FEATURES.getName()).size()); + for (String message : logCapture.getMessageStrings()) { + assertTrue("unexpected error log: " + message, !message.startsWith("ERROR:")); + } + assertThat(logCapture.getMessageStrings(), hasItem( + "INFO:Flag overrides in effect: 1 flag (" + present + ": 1 flag; " + missing + ": absent)")); + } + } + + @Test + public void startsWithNoFilesAndLogsNoneInEffect() throws Exception { + try (TempDir dir = TempDir.create()) { + Path missing = dir.getPath().resolve("missing.json"); + RecordingSink sink = new RecordingSink(); + start(FileOverrides.source().filePaths(missing), sink); + + Map> snapshot = sink.await(); + assertTrue(snapshot.get(FEATURES.getName()).isEmpty()); + assertThat(logCapture.getMessageStrings(), hasItem("INFO:Flag overrides: none in effect (" + missing + ": absent)")); + } + } + + @Test + public void emptyDocumentLogsNoEntries() throws Exception { + try (TempDir dir = TempDir.create()) { + Path file = write(dir, "empty.json", "{}"); + RecordingSink sink = new RecordingSink(); + start(FileOverrides.source().filePaths(file), sink); + sink.await(); + assertThat(logCapture.getMessageStrings(), hasItem("INFO:Flag overrides: none in effect (" + file + ": no entries)")); + } + } + + @Test + public void malformedFileAtStartupSuppliesNothingAndLogsError() throws Exception { + try (TempDir dir = TempDir.create()) { + Path file = write(dir, "bad.json", "{\"flagValues\""); + RecordingSink sink = new RecordingSink(); + start(FileOverrides.source().filePaths(file), sink); + sink.assertNoSnapshot(200); + assertThat(logCapture.getMessageStrings(), hasItem(startsWith("ERROR:Unable to load flags:"))); + } + } + + private void reloadsOnChange(ChangeDetection mode) throws Exception { + try (TempDir dir = TempDir.create()) { + Path file = write(dir, "overrides.json", docWith("flag", "first")); + RecordingSink sink = new RecordingSink(); + start(FileOverrides.source().filePaths(file).changeDetection(mode).pollInterval(Duration.ofSeconds(1)), sink); + assertEquals("first", flagValue(sink.await(), "flag")); + + // A changed file is reloaded. + Files.write(file, docWith("flag", "second").getBytes()); + assertEquals("second", flagValue(sink.await(), "flag")); + + // A malformed edit keeps the last good overrides and supplies nothing. + Files.write(file, "{\"flagValues\"".getBytes()); + sink.assertNoSnapshot(1500); + assertThat(logCapture.getMessageStrings(), hasItem(startsWith("ERROR:Unable to load flags:"))); + + // A later good edit is applied. + Files.write(file, docWith("flag", "third").getBytes()); + assertEquals("third", flagValue(sink.await(), "flag")); + + // Deleting the file removes its overrides. + Files.delete(file); + assertTrue(sink.await().get(FEATURES.getName()).isEmpty()); + + // Recreating the file brings them back. + Files.write(file, docWith("flag", "fourth").getBytes()); + assertEquals("fourth", flagValue(sink.await(), "flag")); + } + } + + @Test + public void pollingModeReloadsOnChange() throws Exception { + reloadsOnChange(ChangeDetection.POLLING); + } + + @Test + public void watchingModeReloadsOnChange() throws Exception { + reloadsOnChange(ChangeDetection.WATCHING); + } + + @Test + public void fileThatDoesNotExistYetTakesEffectWhenItAppears() throws Exception { + for (ChangeDetection mode : ChangeDetection.values()) { + try (TempDir dir = TempDir.create()) { + Path file = dir.getPath().resolve("later.json"); + RecordingSink sink = new RecordingSink(); + start(FileOverrides.source().filePaths(file).changeDetection(mode), sink); + assertTrue(sink.await().get(FEATURES.getName()).isEmpty()); + + Files.write(file, docWith("flag", "appeared").getBytes()); + assertEquals(mode.toString(), "appeared", flagValue(sink.await(), "flag")); + } + } + } + + @Test + public void pollIntervalBelowMinimumIsRaisedWithWarning() throws Exception { + try (TempDir dir = TempDir.create()) { + Path file = write(dir, "overrides.json", "{}"); + RecordingSink sink = new RecordingSink(); + OverrideSource source = start(FileOverrides.source().filePaths(file).pollInterval(Duration.ofMillis(10)), sink); + assertEquals(FileOverrides.MINIMUM_POLL_INTERVAL, ((FileOverrideSourceImpl) source).getPollInterval()); + assertThat(logCapture.getMessageStrings(), hasItem( + "WARN:Poll interval PT0.01S is below the minimum; using PT1S")); + } + } + + @Test + public void pollIntervalAtOrAboveMinimumIsKept() throws Exception { + try (TempDir dir = TempDir.create()) { + Path file = write(dir, "overrides.json", "{}"); + OverrideSource source = start(FileOverrides.source().filePaths(file).pollInterval(Duration.ofSeconds(3)), + new RecordingSink()); + assertEquals(Duration.ofSeconds(3), ((FileOverrideSourceImpl) source).getPollInterval()); + OverrideSource defaulted = start(FileOverrides.source().filePaths(file), new RecordingSink()); + assertEquals(FileOverrides.DEFAULT_POLL_INTERVAL, ((FileOverrideSourceImpl) defaulted).getPollInterval()); + } + } + + @Test + public void failedLoadIsRetriedWithoutAFileChange() throws Exception { + // Watching mode delivers no notification while the file is untouched, so only the source's own + // retry can attempt the load again. + try (TempDir dir = TempDir.create()) { + Path file = write(dir, "bad.json", "{\"flagValues\""); + RecordingSink sink = new RecordingSink(); + start(FileOverrides.source().filePaths(file).changeDetection(ChangeDetection.WATCHING), sink); + assertThat(logCapture.getMessageStrings(), hasItem(startsWith("ERROR:Unable to load flags:"))); + + long deadline = System.currentTimeMillis() + WAIT_MILLIS; + while (!logCapture.getMessageStrings().contains("DEBUG:Retrying flag data load after earlier failure")) { + if (System.currentTimeMillis() > deadline) { + fail("the failed load was not retried"); + } + Thread.sleep(50); + } + sink.assertNoSnapshot(100); + } + } + + @Test + public void watchingModeIgnoresPollIntervalMinimum() throws Exception { + try (TempDir dir = TempDir.create()) { + Path file = write(dir, "overrides.json", "{}"); + RecordingSink sink = new RecordingSink(); + start(FileOverrides.source().filePaths(file).changeDetection(ChangeDetection.WATCHING) + .pollInterval(Duration.ofMillis(10)), sink); + for (String message : logCapture.getMessageStrings()) { + assertTrue(message, !message.startsWith("WARN:Poll interval")); + } + } + } + + private static int liveThreadsNamed(String prefix) { + int count = 0; + for (Thread t : Thread.getAllStackTraces().keySet()) { + if (t.isAlive() && t.getName().startsWith(prefix)) { + count++; + } + } + return count; + } + + private static void awaitLiveThreadsNamed(String prefix, int expected) throws InterruptedException { + long deadline = System.currentTimeMillis() + WAIT_MILLIS; + while (liveThreadsNamed(prefix) != expected) { + if (System.currentTimeMillis() > deadline) { + fail("expected " + expected + " live threads named " + prefix + " but found " + liveThreadsNamed(prefix)); + } + Thread.sleep(20); + } + } + + private void closeReleasesThreads(ChangeDetection mode, String threadPrefix) throws Exception { + try (TempDir dir = TempDir.create()) { + Path file = write(dir, "overrides.json", docWith("flag", "first")); + int baseline = liveThreadsNamed(threadPrefix); + RecordingSink sink = new RecordingSink(); + OverrideSource source = start(FileOverrides.source().filePaths(file).changeDetection(mode) + .pollInterval(Duration.ofSeconds(1)), sink); + sink.await(); + awaitLiveThreadsNamed(threadPrefix, baseline + 1); + + source.close(); + source.close(); + + awaitLiveThreadsNamed(threadPrefix, baseline); + Files.write(file, docWith("flag", "second").getBytes()); + sink.assertNoSnapshot(1500); + } + } + + @Test + public void closeIsIdempotentAndStopsThePoller() throws Exception { + closeReleasesThreads(ChangeDetection.POLLING, "LaunchDarkly-FileDataPoller"); + } + + @Test + public void closeIsIdempotentAndStopsTheWatcher() throws Exception { + closeReleasesThreads(ChangeDetection.WATCHING, "LaunchDarkly-FileDataWatcher"); + } + + @Test + public void countsTextFormatsCounts() { + assertEquals("1 flag", FileOverrideSourceImpl.countsText(1, 0)); + assertEquals("2 flags, 1 segment", FileOverrideSourceImpl.countsText(2, 1)); + assertEquals("3 segments", FileOverrideSourceImpl.countsText(0, 3)); + assertEquals("", FileOverrideSourceImpl.countsText(0, 0)); + } + + @Test + public void relativePathIsResolvedAgainstWorkingDirectory() throws Exception { + Path relative = java.nio.file.Paths.get("no-such-dir-for-override-test", "overrides.json"); + RecordingSink sink = new RecordingSink(); + start(FileOverrides.source().filePaths(relative.toString()), sink); + assertTrue(sink.await().get(FEATURES.getName()).isEmpty()); + assertThat(logCapture.getMessageStrings(), hasItem(containsString(relative.toAbsolutePath().normalize().toString()))); + } + + @Test + public void loggerUsesSubLoggerName() throws Exception { + try (TempDir dir = TempDir.create()) { + Path file = write(dir, "overrides.json", "{}"); + start(FileOverrides.source().filePaths(file), new RecordingSink()); + boolean found = false; + for (LogCapture.Message m : logCapture.getMessages()) { + if (m.getLevel() == LDLogLevel.INFO && m.getLoggerName().endsWith("FileOverrideSource")) { + found = true; + } + } + assertTrue(found); + } + } +} From 056d3f233852a0441d6a73ea7449b8def50c848b Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Thu, 1 Oct 2026 23:25:45 +0000 Subject: [PATCH 16/30] fix: Describe and expect the fallthrough expansion of value-only overrides --- .../sdk/server/integrations/FileOverrides.java | 3 ++- .../sdk/server/integrations/FileOverrideSourceTest.java | 7 ++++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FileOverrides.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FileOverrides.java index 34342505..c62dece2 100644 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FileOverrides.java +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FileOverrides.java @@ -78,7 +78,8 @@ private FileOverrides() {} * The files use the same document format as the file data source (see {@link FileData}): each * file is a JSON or YAML document with optional {@code "flags"}, {@code "flagValues"}, and * {@code "segments"} members. A {@code "flagValues"} entry is expanded into a full flag - * definition that is off and serves the given value as its single variation for every context. + * definition that is on and serves the given value as its single variation by fallthrough for + * every context. * When multiple files are configured, their entries are combined in the configured order, and * the duplicate keys handling decides which file wins for a key that appears more than once. *

diff --git a/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/integrations/FileOverrideSourceTest.java b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/integrations/FileOverrideSourceTest.java index 51d1f942..452b25a7 100644 --- a/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/integrations/FileOverrideSourceTest.java +++ b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/integrations/FileOverrideSourceTest.java @@ -175,12 +175,13 @@ public void initialLoadCompletesBeforeStartReturns() throws Exception { assertNotNull("the initial snapshot must be supplied before start returns", snapshot); assertEquals(7, snapshot.get(FEATURES.getName()).get("full").getVersion()); assertEquals("value", flagValue(snapshot, "simple")); - // A value-only entry is an off flag with version 0. + // A value-only entry is a flag served by fallthrough with version 0. ItemDescriptor simple = snapshot.get(FEATURES.getName()).get("simple"); assertEquals(0, simple.getVersion()); LDValue simpleJson = LDValue.parse(FEATURES.serialize(simple)); - assertEquals(LDValue.of(false), simpleJson.get("on")); - assertEquals(LDValue.of(0), simpleJson.get("offVariation")); + assertEquals(LDValue.of(true), simpleJson.get("on")); + assertEquals(LDValue.of(0), simpleJson.get("fallthrough").get("variation")); + assertEquals(LDValue.ofNull(), simpleJson.get("offVariation")); assertEquals(3, snapshot.get(SEGMENTS.getName()).get("seg").getVersion()); assertThat(logCapture.getMessageStrings(), hasItem( "INFO:Flag overrides in effect: 2 flags, 1 segment (" + file + ": 2 flags, 1 segment)")); From b19388185bd46e318f1f7683d8f747fa087238b4 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Sat, 3 Oct 2026 00:29:03 +0000 Subject: [PATCH 17/30] fix: Pass the logger to the file data poller The poller logs a change callback that throws, so it needs the source's logger. This compiles once the branch includes the poller constructor that takes a logger. --- .../sdk/server/integrations/FileOverrideSourceImpl.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FileOverrideSourceImpl.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FileOverrideSourceImpl.java index 22d719c4..ae4ed4ca 100644 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FileOverrideSourceImpl.java +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FileOverrideSourceImpl.java @@ -96,7 +96,7 @@ public void onError(FileDataException e) { break; case POLLING: default: - poller = new FileDataPoller(paths, pollInterval, reloader::trigger); + poller = new FileDataPoller(paths, pollInterval, reloader::trigger, logger); break; } } From 79d61a945c5e25b0aa351863f561c734abe69d0f Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Sat, 3 Oct 2026 01:39:38 +0000 Subject: [PATCH 18/30] fix: Log a summary of the exception when override files cannot be watched --- .../sdk/server/integrations/FileOverrideSourceImpl.java | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FileOverrideSourceImpl.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FileOverrideSourceImpl.java index ae4ed4ca..d3a8d96f 100644 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FileOverrideSourceImpl.java +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FileOverrideSourceImpl.java @@ -1,6 +1,7 @@ package com.launchdarkly.sdk.server.integrations; import com.launchdarkly.logging.LDLogger; +import com.launchdarkly.logging.LogValues; import com.launchdarkly.sdk.server.integrations.FileDataSourceParsing.FileDataException; import com.launchdarkly.sdk.server.integrations.FileOverrides.ChangeDetection; import com.launchdarkly.sdk.server.integrations.OverrideFileLoader.FileSummary; @@ -91,7 +92,8 @@ public void onError(FileDataException e) { w.start(reloader::trigger); } catch (IOException e) { // COVERAGE: constructing a watcher only fails under unusual OS conditions - logger.error("Unable to watch override files: {}", e.toString()); + logger.error("Unable to watch override files: {}", LogValues.exceptionSummary(e)); + logger.debug(LogValues.exceptionTrace(e)); } break; case POLLING: From b1f718183992725d5f9d29757a0f28659992721b Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Thu, 15 Oct 2026 22:23:22 +0000 Subject: [PATCH 19/30] fix: Drop a file path listed more than once instead of treating its entries as duplicate keys A path listed more than once would make every entry of that file a duplicate of itself: under the default fail handling the load failed, and under ignore the first listing won by accident. The builder now drops the repeats, keeps the first occurrence in its place in the order, and logs a warning once at build time. This is the behavior the Node SDK has. --- .../integrations/FileOverrideSourceBuilder.java | 15 +++++++++++---- .../integrations/FileOverrideSourceTest.java | 15 +++++++++++++++ 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FileOverrideSourceBuilder.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FileOverrideSourceBuilder.java index ea19763e..96b23d73 100644 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FileOverrideSourceBuilder.java +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FileOverrideSourceBuilder.java @@ -11,7 +11,9 @@ import java.nio.file.Paths; import java.time.Duration; import java.util.ArrayList; +import java.util.LinkedHashSet; import java.util.List; +import java.util.Set; /** * Configures the file-based override source. Obtain an instance with {@link FileOverrides#source()}, @@ -129,12 +131,17 @@ public OverrideSource build(ClientContext context) { if (pollInterval == null) { throw new IllegalArgumentException("a poll interval is required for the file-based override source"); } - List absolutePaths = new ArrayList<>(filePaths.size()); + LDLogger logger = context.getBaseLogger().subLogger("FileOverrideSource"); + + // A path listed more than once would make every entry of that file a duplicate of itself, so + // the repeats are dropped and the first occurrence keeps its place in the order. + Set absolutePaths = new LinkedHashSet<>(); for (Path p : filePaths) { absolutePaths.add(p.toAbsolutePath().normalize()); } - - LDLogger logger = context.getBaseLogger().subLogger("FileOverrideSource"); + if (absolutePaths.size() != filePaths.size()) { + logger.warn("File paths list the same file more than once; the repeats are ignored"); + } Duration effectivePollInterval = pollInterval; if (changeDetection == ChangeDetection.POLLING && pollInterval.compareTo(FileOverrides.MINIMUM_POLL_INTERVAL) < 0) { @@ -142,6 +149,6 @@ public OverrideSource build(ClientContext context) { effectivePollInterval = FileOverrides.MINIMUM_POLL_INTERVAL; } - return new FileOverrideSourceImpl(absolutePaths, duplicateKeysHandling, changeDetection, effectivePollInterval, logger); + return new FileOverrideSourceImpl(new ArrayList<>(absolutePaths), duplicateKeysHandling, changeDetection, effectivePollInterval, logger); } } diff --git a/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/integrations/FileOverrideSourceTest.java b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/integrations/FileOverrideSourceTest.java index 452b25a7..f80a7643 100644 --- a/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/integrations/FileOverrideSourceTest.java +++ b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/integrations/FileOverrideSourceTest.java @@ -161,6 +161,21 @@ public void nullDuplicateKeysHandlingSelectsFail() throws Exception { } } + @Test + public void repeatedPathIsDroppedWithAWarning() throws Exception { + // Under the default fail handling the same file listed twice would make every one of its + // keys a duplicate of itself. The repeat is ignored and the file loads once. + try (TempDir dir = TempDir.create()) { + Path file = write(dir, "a.json", docWith("flag", "value")); + RecordingSink sink = new RecordingSink(); + start(FileOverrides.source().filePaths(file, file), sink); + + assertEquals("value", flagValue(sink.await(), "flag")); + assertThat(logCapture.getMessageStrings(), + hasItem("WARN:File paths list the same file more than once; the repeats are ignored")); + } + } + @Test public void initialLoadCompletesBeforeStartReturns() throws Exception { try (TempDir dir = TempDir.create()) { From 8cdcabc2f22ae9ac1f40537a5d57289a0efc11e9 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Wed, 28 Oct 2026 22:33:40 +0000 Subject: [PATCH 20/30] fix: Reject watching a file path that is not on the default file system The watcher uses the default file system's watch service, which cannot watch a path of another provider such as a zip file system. Registering one threw an unchecked exception from start, so a configuration error surfaced as a failed start. The builder now rejects such a path when watching is selected, so the error is reported when the client is created. Polling reads such a path through its own provider and still accepts it. --- .../FileOverrideSourceBuilder.java | 17 ++++++++- .../integrations/FileOverrideSourceTest.java | 37 +++++++++++++++++++ 2 files changed, 53 insertions(+), 1 deletion(-) diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FileOverrideSourceBuilder.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FileOverrideSourceBuilder.java index 96b23d73..40e8130b 100644 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FileOverrideSourceBuilder.java +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FileOverrideSourceBuilder.java @@ -6,6 +6,7 @@ import com.launchdarkly.sdk.server.subsystems.ComponentConfigurer; import com.launchdarkly.sdk.server.subsystems.OverrideSource; +import java.nio.file.FileSystems; import java.nio.file.InvalidPathException; import java.nio.file.Path; import java.nio.file.Paths; @@ -88,7 +89,9 @@ public FileOverrideSourceBuilder duplicateKeysHandling(FileData.DuplicateKeysHan /** * Selects how the source detects file changes. The default is {@link ChangeDetection#POLLING}. - * The two modes are alternatives, so setting one replaces the other. + * The two modes are alternatives, so setting one replaces the other. {@link ChangeDetection#WATCHING} + * requires every file path to be on the default file system; a path of another file system provider, + * such as a zip file system, can be polled but not watched, and {@link #build(ClientContext)} rejects it. * * @param changeDetection the change detection mode * @return the same builder @@ -131,6 +134,18 @@ public OverrideSource build(ClientContext context) { if (pollInterval == null) { throw new IllegalArgumentException("a poll interval is required for the file-based override source"); } + // Change notifications come from the default file system's watch service, which cannot watch a + // path of another file system provider, such as a zip file system. That is a configuration + // error, reported here rather than when the source starts. Polling reads such a path through its + // own provider. + if (changeDetection == ChangeDetection.WATCHING) { + for (Path p : filePaths) { + if (!p.getFileSystem().equals(FileSystems.getDefault())) { + throw new IllegalArgumentException( + "the file-based override source can watch only files on the default file system; use polling for " + p); + } + } + } LDLogger logger = context.getBaseLogger().subLogger("FileOverrideSource"); // A path listed more than once would make every entry of that file a duplicate of itself, so diff --git a/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/integrations/FileOverrideSourceTest.java b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/integrations/FileOverrideSourceTest.java index f80a7643..589ccf62 100644 --- a/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/integrations/FileOverrideSourceTest.java +++ b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/integrations/FileOverrideSourceTest.java @@ -19,10 +19,15 @@ import org.junit.After; import org.junit.Test; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.nio.file.FileSystem; +import java.nio.file.FileSystems; import java.nio.file.Files; import java.nio.file.Path; import java.time.Duration; import java.util.ArrayList; +import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -148,6 +153,38 @@ public void buildRequiresPollInterval() { } } + @Test + public void buildRejectsWatchingAPathOnAnotherFileSystem() throws Exception { + // The default watch service cannot watch such a path. Rejecting it here reports the + // configuration error when the client is created, instead of a failure when the source starts. + try (TempDir dir = TempDir.create(); FileSystem zip = newZipFileSystem(dir)) { + Path file = zip.getPath("/overrides.json"); + try { + FileOverrides.source().filePaths(file).changeDetection(ChangeDetection.WATCHING).build(context); + fail("expected exception"); + } catch (IllegalArgumentException e) { + assertThat(e.getMessage(), containsString("can watch only files on the default file system")); + } + } + } + + @Test + public void pollingLoadsAPathOnAnotherFileSystem() throws Exception { + try (TempDir dir = TempDir.create(); FileSystem zip = newZipFileSystem(dir)) { + Path file = zip.getPath("/overrides.json"); + Files.write(file, docWith("flag", "value").getBytes(StandardCharsets.UTF_8)); + RecordingSink sink = new RecordingSink(); + start(FileOverrides.source().filePaths(file).changeDetection(ChangeDetection.POLLING), sink); + + assertEquals("value", flagValue(sink.await(), "flag")); + } + } + + private static FileSystem newZipFileSystem(TempDir dir) throws Exception { + URI uri = URI.create("jar:" + dir.getPath().resolve("overrides.zip").toUri()); + return FileSystems.newFileSystem(uri, Collections.singletonMap("create", "true")); + } + @Test public void nullDuplicateKeysHandlingSelectsFail() throws Exception { try (TempDir dir = TempDir.create()) { From 051fd144ca50db199e09acb5790f7e21973b6f0e Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Wed, 28 Oct 2026 22:47:56 +0000 Subject: [PATCH 21/30] fix: Poll the override files when they cannot be watched When watching was selected and the watch service could not be created, for example because the limit on inotify instances was reached, the source logged the failure and ran with no change detection, so a later edit never took effect. It now polls the files instead, at no less than the minimum poll interval, and the log says so. --- .../integrations/FileOverrideSourceImpl.java | 32 +++++++++++++++++-- .../integrations/FileOverrideSourceTest.java | 26 +++++++++++++++ 2 files changed, 55 insertions(+), 3 deletions(-) diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FileOverrideSourceImpl.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FileOverrideSourceImpl.java index d3a8d96f..5b796e37 100644 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FileOverrideSourceImpl.java +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FileOverrideSourceImpl.java @@ -26,11 +26,19 @@ * effect, and the reload is retried. */ final class FileOverrideSourceImpl implements OverrideSource { + /** + * Creates the watcher for watching mode. Tests substitute one that fails. + */ + interface WatcherFactory { + FileDataWatcher create(List paths, LDLogger logger) throws IOException; + } + private final List paths; private final ChangeDetection changeDetection; private final Duration pollInterval; private final LDLogger logger; private final OverrideFileLoader loader; + private final WatcherFactory watcherFactory; private volatile FileDataReloader reloader; private volatile FileDataPoller poller; @@ -43,12 +51,24 @@ final class FileOverrideSourceImpl implements OverrideSource { ChangeDetection changeDetection, Duration pollInterval, LDLogger logger + ) { + this(paths, duplicateKeysHandling, changeDetection, pollInterval, logger, FileDataWatcher::create); + } + + FileOverrideSourceImpl( + List paths, + FileData.DuplicateKeysHandling duplicateKeysHandling, + ChangeDetection changeDetection, + Duration pollInterval, + LDLogger logger, + WatcherFactory watcherFactory ) { this.paths = new ArrayList<>(paths); this.changeDetection = changeDetection; this.pollInterval = pollInterval; this.logger = logger; this.loader = new OverrideFileLoader(paths, duplicateKeysHandling); + this.watcherFactory = watcherFactory; } /** @@ -87,13 +107,19 @@ public void onError(FileDataException e) { switch (changeDetection) { case WATCHING: try { - FileDataWatcher w = FileDataWatcher.create(paths, logger); + FileDataWatcher w = watcherFactory.create(paths, logger); watcher = w; w.start(reloader::trigger); } catch (IOException e) { - // COVERAGE: constructing a watcher only fails under unusual OS conditions - logger.error("Unable to watch override files: {}", LogValues.exceptionSummary(e)); + // The watch service itself cannot be created, for example because the limit on inotify + // instances is reached. Without change detection a later edit would never take effect, so + // the source polls the files instead, no more often than polling mode allows. + Duration interval = pollInterval.compareTo(FileOverrides.MINIMUM_POLL_INTERVAL) < 0 + ? FileOverrides.MINIMUM_POLL_INTERVAL : pollInterval; + logger.error("Unable to watch override files; polling them instead every {}: {}", interval, + LogValues.exceptionSummary(e)); logger.debug(LogValues.exceptionTrace(e)); + poller = new FileDataPoller(paths, interval, reloader::trigger, logger); } break; case POLLING: diff --git a/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/integrations/FileOverrideSourceTest.java b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/integrations/FileOverrideSourceTest.java index 589ccf62..0a3ebb74 100644 --- a/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/integrations/FileOverrideSourceTest.java +++ b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/integrations/FileOverrideSourceTest.java @@ -19,6 +19,7 @@ import org.junit.After; import org.junit.Test; +import java.io.IOException; import java.net.URI; import java.nio.charset.StandardCharsets; import java.nio.file.FileSystem; @@ -372,6 +373,31 @@ public void watchingModeReloadsOnChange() throws Exception { reloadsOnChange(ChangeDetection.WATCHING); } + @Test + public void watchingFallsBackToPollingWhenTheWatcherCannotBeCreated() throws Exception { + // Without change detection a later edit would never take effect. The source polls instead, at + // no less than the minimum poll interval, which watching mode otherwise ignores. + try (TempDir dir = TempDir.create()) { + Path file = write(dir, "overrides.json", docWith("flag", "first")); + FileOverrideSourceImpl source = new FileOverrideSourceImpl( + Collections.singletonList(file.toAbsolutePath()), FileData.DuplicateKeysHandling.FAIL, + ChangeDetection.WATCHING, Duration.ofMillis(10), context.getBaseLogger().subLogger("FileOverrideSource"), + (paths, logger) -> { + throw new IOException("watch service unavailable"); + }); + sources.add(source); + RecordingSink sink = new RecordingSink(); + source.start(sink); + assertEquals("first", flagValue(sink.await(), "flag")); + + Files.write(file, docWith("flag", "second").getBytes()); + assertEquals("second", flagValue(sink.await(), "flag")); + assertThat(logCapture.getMessageStrings(), hasItem(allOf( + startsWith("ERROR:Unable to watch override files; polling them instead every PT1S"), + containsString("watch service unavailable")))); + } + } + @Test public void fileThatDoesNotExistYetTakesEffectWhenItAppears() throws Exception { for (ChangeDetection mode : ChangeDetection.values()) { From 2ab2fe7a760c1223221999648c7e71f573a48790 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Wed, 28 Oct 2026 23:01:57 +0000 Subject: [PATCH 22/30] docs: Note that a flagValues override is not available to client-side SDKs A flagValues entry becomes a flag without client-side availability, so allFlagsState with CLIENT_SIDE_ONLY leaves it out even when the flag it overrides is client-side. A full definition with clientSide is the way to override such a flag. A test pins both. --- .../server/integrations/FileOverrides.java | 6 ++++- .../sdk/server/LDClientOverridesTest.java | 25 +++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FileOverrides.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FileOverrides.java index c62dece2..636bc9b0 100644 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FileOverrides.java +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FileOverrides.java @@ -79,7 +79,11 @@ private FileOverrides() {} * file is a JSON or YAML document with optional {@code "flags"}, {@code "flagValues"}, and * {@code "segments"} members. A {@code "flagValues"} entry is expanded into a full flag * definition that is on and serves the given value as its single variation by fallthrough for - * every context. + * every context. Such a flag is not available to client-side SDKs, so + * {@link com.launchdarkly.sdk.server.LDClient#allFlagsState} with + * {@link com.launchdarkly.sdk.server.FlagsStateOption#CLIENT_SIDE_ONLY} leaves it out, even when + * the flag it overrides is available to them. To override such a flag, define it in + * {@code "flags"} with {@code "clientSide": true}. * When multiple files are configured, their entries are combined in the configured order, and * the duplicate keys handling decides which file wins for a key that appears more than once. *

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 index 11b79578..10d64336 100644 --- 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 @@ -10,6 +10,7 @@ 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.integrations.FileOverrides; import com.launchdarkly.sdk.server.integrations.TestDataV2; import com.launchdarkly.sdk.server.interfaces.FlagChangeEvent; import com.launchdarkly.sdk.server.interfaces.DataStoreStatusProvider.CacheStats; @@ -21,10 +22,15 @@ import com.launchdarkly.sdk.server.subsystems.DataStoreTypes.KeyedItems; import com.launchdarkly.sdk.server.subsystems.DataSystemConfiguration.DataStoreMode; import com.launchdarkly.sdk.server.subsystems.OverrideSource; +import com.launchdarkly.testhelpers.TempDir; import org.junit.Test; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; import java.time.Duration; +import java.util.Arrays; import java.util.Collections; import java.util.HashSet; import java.util.Map; @@ -168,6 +174,25 @@ public void allFlagsStateContainsOnlyOverridesWhenClientIsNotInitialized() throw } } + @Test + public void clientSideAllFlagsStateKeepsADefinitionWithClientSideButNotAFlagValuesEntry() throws Exception { + // A flagValues entry is not available to client-side SDKs. A full definition that sets + // clientSide is, so that is how an operator overrides a flag that client-side SDKs use. + try (TempDir dir = TempDir.create()) { + Path file = dir.getPath().resolve("overrides.json"); + Files.write(file, ("{\"flagValues\": {\"value-flag\": true}, \"flags\": {\"full-flag\": {" + + "\"key\": \"full-flag\", \"version\": 1, \"on\": true, \"variations\": [true], " + + "\"fallthrough\": {\"variation\": 0}, \"clientSide\": true}}}").getBytes(StandardCharsets.UTF_8)); + try (LDClient client = new LDClient(SDK_KEY, uninitializedConfig(FileOverrides.source().filePaths(file)))) { + assertEquals(new HashSet<>(Arrays.asList("full-flag", "value-flag")), + client.allFlagsState(CONTEXT).toValuesMap().keySet()); + FeatureFlagsState clientSide = client.allFlagsState(CONTEXT, FlagsStateOption.CLIENT_SIDE_ONLY); + assertTrue(clientSide.isValid()); + assertEquals(Collections.singletonMap("full-flag", LDValue.of(true)), clientSide.toValuesMap()); + } + } + } + @Test public void allFlagsStateOverridesOnlyWarningIsLoggedOnce() throws Exception { TestOverrideSource source = new TestOverrideSource(flagsOnly(valueFlag("overridden", "override-value"))); From d9ec2afdb0b2d4a43f069a10f9d159da7aad13df Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Fri, 25 Sep 2026 16:08:48 -0700 Subject: [PATCH 23/30] feat: Add the flag-overrides capability to the contract test service The contract test service advertises the flag-overrides capability and accepts the overrides object in the SDK configuration: filePaths, duplicateKeysHandling, changeDetection, and pollIntervalMs. The block configures FileOverrides.source() on the data system builder. An overrides block without a dataSystem block is rejected, because the override source is an option of the FDv2 data system. The service reads its listening port from the PORT environment variable when set, which the Makefile already passes, so several test services can run side by side. The default stays 8000. --- .../main/java/sdktest/Representations.java | 15 +++++++ .../main/java/sdktest/SdkClientEntity.java | 45 +++++++++++++++++++ .../src/main/java/sdktest/TestService.java | 14 ++++-- 3 files changed, 70 insertions(+), 4 deletions(-) diff --git a/lib/sdk/server/contract-tests/service/src/main/java/sdktest/Representations.java b/lib/sdk/server/contract-tests/service/src/main/java/sdktest/Representations.java index cd7593e7..a2e03b1c 100644 --- a/lib/sdk/server/contract-tests/service/src/main/java/sdktest/Representations.java +++ b/lib/sdk/server/contract-tests/service/src/main/java/sdktest/Representations.java @@ -34,6 +34,21 @@ public static class SdkConfigParams { SdkConfigServiceEndpointParams serviceEndpoints; SdkConfigHookParams hooks; SdkConfigDataSystemParams dataSystem; + SdkConfigOverridesParams overrides; + } + + /** + * Configuration for the SDK's file-based flag overrides (the "flag-overrides" capability). + */ + public static class SdkConfigOverridesParams { + /** Absolute paths of the override files, in precedence order. */ + String[] filePaths; + /** "fail" (the default) or "ignore". */ + String duplicateKeysHandling; + /** "polling" or "watching"; null means the SDK default. */ + String changeDetection; + /** The polling interval in milliseconds; the SDK may raise it to its minimum. */ + Long pollIntervalMs; } public static class SdkConfigStreamParams { diff --git a/lib/sdk/server/contract-tests/service/src/main/java/sdktest/SdkClientEntity.java b/lib/sdk/server/contract-tests/service/src/main/java/sdktest/SdkClientEntity.java index f6bb9d96..53da61ac 100644 --- a/lib/sdk/server/contract-tests/service/src/main/java/sdktest/SdkClientEntity.java +++ b/lib/sdk/server/contract-tests/service/src/main/java/sdktest/SdkClientEntity.java @@ -32,6 +32,9 @@ import com.launchdarkly.sdk.server.integrations.FDv2PollingInitializerBuilder; import com.launchdarkly.sdk.server.integrations.FDv2PollingSynchronizerBuilder; import com.launchdarkly.sdk.server.integrations.FDv2StreamingSynchronizerBuilder; +import com.launchdarkly.sdk.server.integrations.FileData; +import com.launchdarkly.sdk.server.integrations.FileOverrideSourceBuilder; +import com.launchdarkly.sdk.server.integrations.FileOverrides; import com.launchdarkly.sdk.server.interfaces.BigSegmentStoreStatusProvider; import com.launchdarkly.sdk.server.subsystems.DataSourceBuilder; import com.launchdarkly.sdk.server.datasources.Initializer; @@ -68,6 +71,7 @@ import sdktest.Representations.SdkConfigParams; import sdktest.Representations.SdkConfigDataSystemParams; import sdktest.Representations.SdkConfigDataInitializerParams; +import sdktest.Representations.SdkConfigOverridesParams; import sdktest.Representations.SdkConfigSynchronizerParams; import sdktest.Representations.SdkConfigPollingParams; import sdktest.Representations.SdkConfigStreamingParams; @@ -587,12 +591,53 @@ private LDConfig buildSdkConfig(SdkConfigParams params, String tag) { dataSystemBuilder.fDv1FallbackSynchronizer(fdv1Polling); } + if (params.overrides != null) { + dataSystemBuilder.overrides(makeOverridesConfig(params.overrides)); + } + builder.dataSystem(dataSystemBuilder); + } else if (params.overrides != null) { + throw new IllegalArgumentException("flag overrides require the data system to be configured"); } return builder.build(); } + private static FileOverrideSourceBuilder makeOverridesConfig(SdkConfigOverridesParams params) { + FileOverrideSourceBuilder overrides = FileOverrides.source(); + if (params.filePaths != null) { + overrides.filePaths(params.filePaths); + } + if (params.duplicateKeysHandling != null) { + switch (params.duplicateKeysHandling) { + case "fail": + overrides.duplicateKeysHandling(FileData.DuplicateKeysHandling.FAIL); + break; + case "ignore": + overrides.duplicateKeysHandling(FileData.DuplicateKeysHandling.IGNORE); + break; + default: + throw new IllegalArgumentException("unknown duplicate keys handling: " + params.duplicateKeysHandling); + } + } + if (params.changeDetection != null) { + switch (params.changeDetection) { + case "polling": + overrides.changeDetection(FileOverrides.ChangeDetection.POLLING); + break; + case "watching": + overrides.changeDetection(FileOverrides.ChangeDetection.WATCHING); + break; + default: + throw new IllegalArgumentException("unknown change detection mode: " + params.changeDetection); + } + } + if (params.pollIntervalMs != null) { + overrides.pollInterval(Duration.ofMillis(params.pollIntervalMs)); + } + return overrides; + } + private DataSourceBuilder createSynchronizer( SdkConfigSynchronizerParams synchronizer, String payloadFilter) { diff --git a/lib/sdk/server/contract-tests/service/src/main/java/sdktest/TestService.java b/lib/sdk/server/contract-tests/service/src/main/java/sdktest/TestService.java index 45eeead7..51f9fb4e 100644 --- a/lib/sdk/server/contract-tests/service/src/main/java/sdktest/TestService.java +++ b/lib/sdk/server/contract-tests/service/src/main/java/sdktest/TestService.java @@ -21,7 +21,7 @@ import sdktest.Representations.Status; public class TestService { - private static final int PORT = 8000; + private static final int DEFAULT_PORT = 8000; private static final String[] CAPABILITIES = new String[]{ "all-flags-client-side-only", "all-flags-details-only-for-tracked-flags", @@ -47,7 +47,8 @@ public class TestService { "fdv1-fallback", "instance-id", "retry-conformance-fdv1-streaming", - "retry-conformance-fdv1-polling" + "retry-conformance-fdv1-polling", + "flag-overrides" }; static final Gson gson = new GsonBuilder().serializeNulls().create(); @@ -83,10 +84,15 @@ public static void main(String[] args) throws Exception { .addRegex("POST", Pattern.compile("/clients/(.*)"), ctx -> service.postClientCommand(diableKeepAlive(ctx))) .addRegex("DELETE", Pattern.compile("/clients/(.*)"), ctx -> service.deleteClient(diableKeepAlive(ctx))); - HttpServer server = HttpServer.start(PORT, router); + int port = DEFAULT_PORT; + String portEnv = System.getenv("PORT"); + if (portEnv != null && !portEnv.isEmpty()) { + port = Integer.parseInt(portEnv); + } + HttpServer server = HttpServer.start(port, router); server.getRecorder().setEnabled(false); // don't accumulate a request log - System.out.println("Listening on port " + PORT); + System.out.println("Listening on port " + port); // need to explicitly sleep because HttpServer now starts as a daemon thread while (true) { From 031679159d44b53fc2883156a282a4a867c7242e Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Mon, 2 Nov 2026 23:41:06 +0000 Subject: [PATCH 24/30] fix: Keep the overrides in effect while an override file is rewritten in place An application or editor that rewrites an override file in place truncates it and then writes the new content. A reload in between found the file empty, chose YAML for it because it did not start with '{', and applied a document with no entries, so the client briefly served LaunchDarkly data for every overridden flag. The override source now chooses the parser by the file's extension (.yaml and .yml are YAML, any other file is JSON), and an empty or white-space JSON file fails to parse, so the last good overrides stay. A file whose content is in effect and is found empty is deferred, whatever its format: the load still reads the other files, the overrides in effect stay, no failure is reported, and the files are read again after the retry delay. The file is applied once it has stayed empty, without being written, for about one retry delay, or after five retry delays at most when it keeps being rewritten. The file data source keeps choosing the parser by content. --- .../server/integrations/FileDataReloader.java | 95 +++- .../integrations/FileDataSourceParsing.java | 34 +- .../FileOverrideSourceBuilder.java | 4 +- .../integrations/FileOverrideSourceImpl.java | 17 +- .../server/integrations/FileOverrides.java | 15 +- .../integrations/OverrideFileLoader.java | 175 ++++++- .../FileDataReloaderEmptiedFileTest.java | 466 ++++++++++++++++++ .../integrations/FileDataReloaderTest.java | 160 +++++- .../FileOverrideSourceInPlaceRewriteTest.java | 261 ++++++++++ .../integrations/FileOverrideSourceTest.java | 32 +- .../server/integrations/InPlaceWrites.java | 49 ++ .../integrations/OverrideFileLoaderTest.java | 400 ++++++++++++++- 12 files changed, 1638 insertions(+), 70 deletions(-) create mode 100644 lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/integrations/FileDataReloaderEmptiedFileTest.java create mode 100644 lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/integrations/FileOverrideSourceInPlaceRewriteTest.java create mode 100644 lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/integrations/InPlaceWrites.java diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FileDataReloader.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FileDataReloader.java index 97a73c07..d5d51897 100644 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FileDataReloader.java +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FileDataReloader.java @@ -6,8 +6,12 @@ import com.launchdarkly.sdk.server.integrations.OverrideFileLoader.LoadResult; import java.io.Closeable; +import java.nio.file.Path; import java.time.Duration; +import java.util.ArrayList; import java.util.Arrays; +import java.util.Collections; +import java.util.List; import java.util.concurrent.ScheduledFuture; import java.util.concurrent.ScheduledThreadPoolExecutor; import java.util.concurrent.ThreadFactory; @@ -23,6 +27,10 @@ * and returns the merged result, or throws {@link FileDataException} when a file cannot be read or * parsed or when the files cannot be merged. *

+ * A file whose content is in effect and is found empty may be in the middle of an in-place rewrite. + * The loader can defer such a file with {@link EmptiedFileException}: the reloader keeps the current + * data, reports no failure, and reads the files again after the retry delay. + *

* The file data source does not use this class. It keeps its own reload behavior. */ final class FileDataReloader implements Closeable { @@ -43,7 +51,46 @@ final class FileDataReloader implements Closeable { * Performs one full load of all configured files. */ interface Loader { - LoadResult load() throws FileDataException; + /** + * Reads every configured file and merges the entries. + * + * @param retryDelay the reloader's retry delay, or zero if it does not retry. A loader may defer + * an empty file for a time based on it, because a retry reads the files again. With zero, + * nothing would read them again, so it must not defer. + * @return the merged result + * @throws FileDataException if a file cannot be read or parsed, or the files cannot be merged + * @throws EmptiedFileException if a file was found empty and is deferred + */ + LoadResult load(Duration retryDelay) throws FileDataException, EmptiedFileException; + + /** + * Called with the result of a load that is now the data in effect: it was applied, or it was + * skipped because it is unchanged. It is called with the reload lock held. + * + * @param result the result + */ + void accepted(LoadResult result); + } + + /** + * Thrown by a {@link Loader} that defers a file it found empty. The file may be in the middle of an + * in-place rewrite: a writer that truncates the file and then writes the new content. + */ + @SuppressWarnings("serial") + static final class EmptiedFileException extends Exception { + private final List paths; + + EmptiedFileException(List paths) { + super("files " + paths + " are empty"); + this.paths = Collections.unmodifiableList(new ArrayList<>(paths)); + } + + /** + * The deferred files, in configuration order. + */ + List getPaths() { + return paths; + } } /** @@ -66,7 +113,8 @@ interface Handler { * Called when a reload fails, including when {@link #apply(LoadResult)} throws, once per * distinct failure. With automatic retries, repeats of an identical failure do not call this * again. A success re-arms it. The reloader logs failures itself, so implementations only need - * to update their own state. + * to update their own state. A load that defers an empty file and reads it again later is not a + * failure and does not call this. * * @param e the failure */ @@ -84,6 +132,9 @@ interface Handler { private final Object reloadLock = new Object(); private byte[] lastGoodHash = null; private String lastErrorMessage = null; + // True from a load that found an emptied file until the next load starts. Written inside the + // reload lock. It only chooses the log message of the retry that such a load arms. + private volatile boolean readAgainPending = false; // Guards the worker and the two timers. private final Object timerLock = new Object(); @@ -97,16 +148,17 @@ interface Handler { * Creates a reloader. The caller performs the initial load with {@link #reloadNow()}, routes * change signals to {@link #trigger()}, and calls {@link #close()} when finished. *

- * The worker thread starts on the first {@link #trigger()} call or the first failed load that - * arms a retry, not here. Construction alone does not start a thread. + * The worker thread starts on the first {@link #trigger()} call or the first load that arms a + * retry, not here. Construction alone does not start a thread. * * @param loader performs each full load * @param handler receives results and failures * @param logger receives log output about reloads and failures * @param debounceDelay how long to wait after a trigger for further triggers to settle before * reloading. Zero or negative reloads on every trigger without waiting. - * @param retryDelay how long to wait after a failed reload before retrying without a trigger. - * Zero or negative disables the automatic retry. + * @param retryDelay how long to wait after a failed reload before retrying without a trigger, + * and after a load that defers an empty file before reading the files again. Zero or negative + * disables the automatic retry, and the loader then parses an empty file as it is. * @param skipUnchanged if true, a successful load whose raw file contents are identical to the * last applied contents does not call the apply callback */ @@ -245,7 +297,8 @@ private void armRetry() { private void runReload(boolean isRetry) { try { if (isRetry) { - logger.debug("Retrying flag data load after earlier failure"); + logger.debug(readAgainPending ? "Reading flag data again after finding an empty file" + : "Retrying flag data load after earlier failure"); } else { logger.info("Reloading flag data after detecting a change"); } @@ -268,19 +321,22 @@ private void runReload(boolean isRetry) { } // Performs one full load of all configured files. It reports whether the load succeeded, which - // decides whether a retry gets armed. A skipped no-op application counts as success. The whole - // set is re-read on every reload because entries are combined across files in order, so a - // change to one file can alter which file wins for a key. + // decides whether a retry gets armed. A skipped no-op application counts as success, and a load + // that defers an empty file does not. The whole set is re-read on every reload because entries are + // combined across files in order, so a change to one file can alter which file wins for a key. private boolean reload() { synchronized (reloadLock) { // A trigger already queued when close was called can still reach here. if (closed.get()) { return true; } + readAgainPending = false; LoadResult result; try { - result = loader.load(); + result = loader.load(retryDelayMillis > 0 ? Duration.ofMillis(retryDelayMillis) : Duration.ZERO); + } catch (EmptiedFileException e) { + return readAgainLater(e.getPaths()); } catch (FileDataException e) { return fail(e); } @@ -296,6 +352,7 @@ private boolean reload() { boolean recovering = lastErrorMessage != null; byte[] hash = result.getContentHash(); if (skipUnchanged && !recovering && hash != null && Arrays.equals(hash, lastGoodHash)) { + loader.accepted(result); return true; } // Nothing is remembered until the consumer has accepted the result. A result that the @@ -308,10 +365,26 @@ private boolean reload() { } lastErrorMessage = null; lastGoodHash = hash; + loader.accepted(result); return true; } } + // Called inside the reload lock. The loader deferred a file that it found empty: a file whose content + // is in effect may be in the middle of an in-place rewrite, a writer that truncates the file and then + // writes the new content. Whatever the file's format, the load keeps the current data and arms the + // retry without reporting a failure, so the files are read again after the retry delay. The change + // signal for the write starts a load sooner. The loader decides how long a file can be deferred, and + // defers nothing when retries are off, because nothing would read the file again. A closed reloader + // arms no retry. + private boolean readAgainLater(List paths) { + for (Path path : paths) { + logger.debug("File {} is empty, possibly while it is being rewritten; reading it again before applying it", path); + } + readAgainPending = true; + return false; + } + private boolean fail(FileDataException e) { // Close may have happened while the files were being read. Deliver nothing and report success // so that no retry is armed. diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FileDataSourceParsing.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FileDataSourceParsing.java index 56fb4f22..1558fab4 100644 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FileDataSourceParsing.java +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FileDataSourceParsing.java @@ -19,6 +19,8 @@ import java.io.InputStream; import java.io.InputStreamReader; import java.io.Reader; +import java.nio.file.Path; +import java.util.Locale; import java.util.Map; import static com.launchdarkly.sdk.server.DataModel.FEATURES; @@ -91,7 +93,23 @@ public static FlagFileParser selectForContent(byte[] data) { Reader r = new InputStreamReader(new ByteArrayInputStream(data)); return detectJson(r) ? JsonParserHolder.getInstance() : YamlParserHolder.getInstance(); } - + + /** + * Chooses the parser by the extension of the file name: a file whose name ends in {@code .yaml} + * or {@code .yml}, in any case, is parsed as YAML, and any other file is parsed as JSON. The + * override source uses this. The file data source keeps choosing by content, with + * {@link #selectForContent(byte[])}. + * + * @param path the file + * @return the parser for the file + */ + static FlagFileParser selectForPath(Path path) { + Path fileName = path.getFileName(); + String name = fileName == null ? "" : fileName.toString().toLowerCase(Locale.ROOT); + return name.endsWith(".yaml") || name.endsWith(".yml") + ? YamlParserHolder.getInstance() : JsonParserHolder.getInstance(); + } + private static boolean detectJson(Reader r) { // A valid JSON file for our purposes must be an object, i.e. it must start with '{' while (true) { @@ -119,11 +137,21 @@ static final class JsonFlagFileParser extends FlagFileParser { @Override public FlagFileRep parse(InputStream input) throws FileDataException, IOException { + JsonElement tree; try { - return parseJson(gson.fromJson(new InputStreamReader(input), JsonElement.class)); + tree = gson.fromJson(new InputStreamReader(input), JsonElement.class); } catch (JsonSyntaxException e) { throw new FileDataException("cannot parse JSON", e); - } + } + if (tree == null) { + // Gson finds no value in a file that is empty or white space only, or, since it reads + // leniently, one that holds only comments. That is not a JSON document. A file that is being + // rewritten in place can be empty for a moment, and the failure keeps its last good data + // until it is complete. The file data source never gets here: it parses a file as JSON only + // when the file starts with '{'. + throw new FileDataException("the file has no content, which is not a JSON document", null); + } + return parseJson(tree); } public FlagFileRep parseJson(JsonElement tree) throws FileDataException, IOException { diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FileOverrideSourceBuilder.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FileOverrideSourceBuilder.java index 40e8130b..5f1a397e 100644 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FileOverrideSourceBuilder.java +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FileOverrideSourceBuilder.java @@ -41,8 +41,8 @@ public final class FileOverrideSourceBuilder implements ComponentConfigurer - * Files are parsed as JSON if their first non-whitespace character is '{'. Otherwise, they are - * parsed as YAML. + * A file whose name ends in {@code .yaml} or {@code .yml}, in any case, is parsed as YAML, and any + * other file is parsed as JSON. * * @param filePaths path(s) to the file(s); may be absolute or relative to the current working directory * @return the same builder diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FileOverrideSourceImpl.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FileOverrideSourceImpl.java index 5b796e37..971acf8f 100644 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FileOverrideSourceImpl.java +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FileOverrideSourceImpl.java @@ -23,7 +23,9 @@ *

* A configured file that does not exist contributes no overrides. A file that exists but cannot * be read or parsed fails that reload: the sink is not called, so the last good overrides stay in - * effect, and the reload is retried. + * effect, and the reload is retried. A file whose content is in effect and is found empty may be in + * the middle of an in-place rewrite, so it is applied only once it has stayed empty for about the + * retry delay, or has been found empty for five retry delays in all. */ final class FileOverrideSourceImpl implements OverrideSource { /** @@ -36,6 +38,7 @@ interface WatcherFactory { private final List paths; private final ChangeDetection changeDetection; private final Duration pollInterval; + private final Duration retryDelay; private final LDLogger logger; private final OverrideFileLoader loader; private final WatcherFactory watcherFactory; @@ -52,20 +55,26 @@ interface WatcherFactory { Duration pollInterval, LDLogger logger ) { - this(paths, duplicateKeysHandling, changeDetection, pollInterval, logger, FileDataWatcher::create); + this(paths, duplicateKeysHandling, changeDetection, pollInterval, FileDataReloader.DEFAULT_RETRY_DELAY, logger, + FileDataWatcher::create); } + /** + * Creates a source with a specific retry delay and watcher factory. Visible for tests. + */ FileOverrideSourceImpl( List paths, FileData.DuplicateKeysHandling duplicateKeysHandling, ChangeDetection changeDetection, Duration pollInterval, + Duration retryDelay, LDLogger logger, WatcherFactory watcherFactory ) { this.paths = new ArrayList<>(paths); this.changeDetection = changeDetection; this.pollInterval = pollInterval; + this.retryDelay = retryDelay; this.logger = logger; this.loader = new OverrideFileLoader(paths, duplicateKeysHandling); this.watcherFactory = watcherFactory; @@ -81,7 +90,7 @@ interface WatcherFactory { @Override public void start(OverrideSink sink) { reloader = new FileDataReloader( - loader::load, + loader, new FileDataReloader.Handler() { @Override public void apply(LoadResult result) { @@ -96,7 +105,7 @@ public void onError(FileDataException e) { }, logger, FileDataReloader.DEFAULT_DEBOUNCE_DELAY, - FileDataReloader.DEFAULT_RETRY_DELAY, + retryDelay, true ); reloader.reloadNow(); diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FileOverrides.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FileOverrides.java index 636bc9b0..3f8d060b 100644 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FileOverrides.java +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FileOverrides.java @@ -77,7 +77,10 @@ private FileOverrides() {} *

* The files use the same document format as the file data source (see {@link FileData}): each * file is a JSON or YAML document with optional {@code "flags"}, {@code "flagValues"}, and - * {@code "segments"} members. A {@code "flagValues"} entry is expanded into a full flag + * {@code "segments"} members. A file whose name ends in {@code .yaml} or {@code .yml}, in any case, + * is parsed as YAML, and any other file is parsed as JSON. This differs from the file data source, + * which parses a file as JSON when it starts with '{' and as YAML otherwise. A + * {@code "flagValues"} entry is expanded into a full flag * definition that is on and serves the given value as its single variation by fallthrough for * every context. Such a flag is not available to client-side SDKs, so * {@link com.launchdarkly.sdk.server.LDClient#allFlagsState} with @@ -94,6 +97,16 @@ private FileOverrides() {} * stay in effect, the source logs the failure, retries after a short delay, and recovers on its * own once the file is readable again. *

+ * A file can be rewritten in place. To remove a file's overrides, delete the file or write a + * document with no entries, such as {}. A JSON file with no content is not a valid + * document: it fails to load, and the overrides in effect stay. A file whose content is in effect + * and that is found empty may be in the middle of a rewrite, so the source keeps the overrides in + * effect until the file has stayed empty for about a second: then an emptied YAML file removes its + * overrides, and an emptied JSON file fails to load. A file that keeps being emptied takes effect + * within about five seconds. YAML that is cut off between two entries can still parse, so for a + * YAML file, replacing its entire contents in one operation, such as renaming a fully written + * temporary file over it, avoids briefly applying only a part of it. + *

* Whenever the set of overrides in effect changes, including at startup, the source logs the * overrides in effect and what each configured file supplied, at Info level. *

diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/OverrideFileLoader.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/OverrideFileLoader.java index ece5b8b7..dcb8aa8b 100644 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/OverrideFileLoader.java +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/OverrideFileLoader.java @@ -2,6 +2,7 @@ import com.google.common.collect.ImmutableList; import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.server.integrations.FileDataReloader.EmptiedFileException; import com.launchdarkly.sdk.server.integrations.FileDataSourceParsing.FileDataException; import com.launchdarkly.sdk.server.integrations.FileDataSourceParsing.FlagFactory; import com.launchdarkly.sdk.server.integrations.FileDataSourceParsing.FlagFileParser; @@ -12,17 +13,22 @@ import java.io.ByteArrayInputStream; import java.io.IOException; +import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.NoSuchFileException; import java.nio.file.Path; +import java.nio.file.attribute.FileTime; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; +import java.time.Duration; import java.util.AbstractMap; import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Objects; +import java.util.function.LongSupplier; import static com.launchdarkly.sdk.server.DataModel.FEATURES; import static com.launchdarkly.sdk.server.DataModel.SEGMENTS; @@ -33,22 +39,46 @@ * 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, 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, and every failure to read or + * parse a file is reported as a file data error. The file data source keeps its own behavior. + *

+ * 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 + * document with no entries. + *

+ * A file that is rewritten in place is empty for a moment, and a load can find it so. The loader + * remembers, for each file, whether it contributed content to the data in effect, and since when it + * has been found empty. A file whose content is in effect and is found empty is deferred, not parsed: + * {@link #load(Duration)} still reads the other files, so that files emptied together are deferred + * from the same load, and then throws {@link EmptiedFileException}. The data in effect stays, and the + * reloader reads the files again after its retry delay. The file is parsed once it has stayed empty, + * without being written, for about one retry delay, or once it has been found empty for five retry + * delays in all, so that a file that keeps being rewritten cannot hold its change back for longer. A file that + * never contributed content, such as a placeholder, and a file whose empty state is in effect are + * parsed right away. The reloader calls {@link #load(Duration)} and {@link #accepted(LoadResult)} one + * call at a time, so this state needs no synchronization. */ -final class OverrideFileLoader { +final class OverrideFileLoader implements FileDataReloader.Loader { + /** + * How many retry delays, counted from when a file was first found empty, a file that keeps being + * found empty can be deferred for. + */ + static final int MAX_DEFERRAL_RETRY_DELAYS = 5; + /** * Describes one configured file after a load. */ static final class FileSummary { private final Path path; private final boolean present; + private final boolean hasContent; private final int flags; private final int segments; - FileSummary(Path path, boolean present, int flags, int segments) { + FileSummary(Path path, boolean present, boolean hasContent, int flags, int segments) { this.path = path; this.present = present; + this.hasContent = hasContent; this.flags = flags; this.segments = segments; } @@ -64,6 +94,13 @@ boolean isPresent() { return present; } + /** + * False when the file does not exist, or is empty or white space only. + */ + boolean hasContent() { + return hasContent; + } + /** * The number of flag entries the merge kept from this file. An entry dropped by the duplicate keys * handling is not counted. @@ -132,8 +169,33 @@ byte[] getContentHash() { } } + /** + * The time during which a file whose content is in effect has been found empty. + */ + private static final class EmptyEpisode { + // When the file was first found empty, and when it was last found empty after a write; from the + // monotonic clock. + final long firstSeenNanos; + long sinceNanos; + // The modification time of the file when it was last found empty after a write, or null if it + // could not be read. It is only compared for equality, never with the clock. + FileTime modified; + + EmptyEpisode(long nowNanos, FileTime modified) { + this.firstSeenNanos = nowNanos; + this.sinceNanos = nowNanos; + this.modified = modified; + } + } + private final List paths; private final FileData.DuplicateKeysHandling duplicateKeysHandling; + private final LongSupplier nanoClock; + // Whether each configured file contributed content to the data in effect. Updated only by + // accepted(LoadResult). A file that is not listed did not. + private final Map appliedHadContent = new HashMap<>(); + // The files whose content is in effect and that are being found empty. + private final Map emptyEpisodes = new HashMap<>(); /** * Creates a loader. @@ -142,31 +204,58 @@ byte[] getContentHash() { * @param duplicateKeysHandling what to do when the same key appears in more than one file */ OverrideFileLoader(List paths, FileData.DuplicateKeysHandling duplicateKeysHandling) { + this(paths, duplicateKeysHandling, System::nanoTime); + } + + /** + * Creates a loader with a specific monotonic clock. Visible for tests. + */ + OverrideFileLoader(List paths, FileData.DuplicateKeysHandling duplicateKeysHandling, + LongSupplier nanoClock) { this.paths = new ArrayList<>(paths); this.duplicateKeysHandling = duplicateKeysHandling; + this.nanoClock = nanoClock; } /** * Reads every configured file in order and merges the entries. * + * @param retryDelay the reloader's retry delay, or zero if it does not retry. A file whose content is + * in effect and that is found empty, or white space only, is deferred while it has stayed empty + * without being written for less than nine tenths of this, and has been found empty for less + * than {@link #MAX_DEFERRAL_RETRY_DELAYS} times this. With zero, every file is parsed as it is. * @return the merged result with a summary of each file - * @throws FileDataException if a file that exists cannot be read or parsed, or the files cannot be merged + * @throws FileDataException if a file that exists cannot be read or parsed, or the files cannot be + * merged, also when another file is deferred + * @throws EmptiedFileException if a file is deferred and every other file loads */ - LoadResult load() throws FileDataException { + @Override + public LoadResult load(Duration retryDelay) throws FileDataException, EmptiedFileException { MessageDigest digest = newDigest(); Map> data = new HashMap<>(); List files = new ArrayList<>(paths.size()); + List deferred = new ArrayList<>(); for (Path path : paths) { byte[] raw; try { raw = Files.readAllBytes(path); } catch (NoSuchFileException e) { // Absence is a valid state, not a failure. - files.add(new FileSummary(path, false, 0, 0)); + emptyEpisodes.remove(path); + files.add(new FileSummary(path, false, false, 0, 0)); continue; } catch (IOException e) { throw new FileDataException("file " + path + ": unable to read file", e); } + boolean hasContent = hasContent(raw); + if (hasContent) { + emptyEpisodes.remove(path); + } else if (deferEmptyFile(path, retryDelay)) { + // The other files are still read, so that each emptied file starts its delay in this load, and + // a failure of another file still fails the load. + deferred.add(path); + continue; + } // One read feeds both the digest and the parse, so the skip-unchanged digest can never disagree // with the content that was applied. if (digest != null) { @@ -176,7 +265,7 @@ LoadResult load() throws FileDataException { int flagsAdded = 0; int segmentsAdded = 0; try { - FlagFileParser parser = FlagFileParser.selectForContent(raw); + FlagFileParser parser = FlagFileParser.selectForPath(path); FlagFileRep fileContents = parser.parse(new ByteArrayInputStream(raw)); if (fileContents.flags != null) { for (Map.Entry e : fileContents.flags.entrySet()) { @@ -209,7 +298,11 @@ LoadResult load() throws FileDataException { // overrides stay in effect. throw new FileDataException("file " + path + ": cannot parse flag or segment data", e); } - files.add(new FileSummary(path, true, flagsAdded, segmentsAdded)); + files.add(new FileSummary(path, true, hasContent, flagsAdded, segmentsAdded)); + } + + if (!deferred.isEmpty()) { + throw new EmptiedFileException(deferred); } ImmutableList.Builder>> all = ImmutableList.builder(); @@ -222,6 +315,70 @@ LoadResult load() throws FileDataException { segments == null ? 0 : segments.size(), digest == null ? null : digest.digest()); } + /** + * 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 + * whether finding it empty later defers it. + * + * @param result the result of a load by this loader + */ + @Override + public void accepted(LoadResult result) { + for (FileSummary file : result.getFiles()) { + appliedHadContent.put(file.getPath(), file.hasContent()); + } + } + + // Decides whether a file that was read empty is deferred. A file whose content is not in effect is + // parsed right away: a placeholder that never had content, or a file whose empty state was applied. + // Otherwise the file may be in the middle of an in-place rewrite. The episode starts when the file + // is first found empty, and a new modification time means a new rewrite, which starts the grace + // again. The file is deferred within nine tenths of a retry delay of the latest start of the grace, + // and within the cap of the first time it was found empty. The retry that a deferral arms comes one + // retry delay later, and a timer can fire slightly early, so the slack lets that retry parse a file + // that has stayed empty instead of deferring it for a second retry delay. The episode is kept when + // the file is parsed, so later loads keep parsing it until it changes. A zero retry delay, which + // means that the reloader does not retry, gives no grace, so nothing is deferred: nothing would read + // the file again. + private boolean deferEmptyFile(Path path, Duration retryDelay) { + if (!Boolean.TRUE.equals(appliedHadContent.get(path))) { + return false; + } + long now = nanoClock.getAsLong(); + FileTime modified = modifiedTime(path); + EmptyEpisode episode = emptyEpisodes.get(path); + if (episode == null) { + episode = new EmptyEpisode(now, modified); + emptyEpisodes.put(path, episode); + } else if (!Objects.equals(modified, episode.modified)) { + episode.sinceNanos = now; + episode.modified = modified; + } + long delay = retryDelay.toNanos(); + return now - episode.sinceNanos < delay - delay / 10 + && now - episode.firstSeenNanos < MAX_DEFERRAL_RETRY_DELAYS * delay; + } + + private static FileTime modifiedTime(Path path) { + try { + return Files.getLastModifiedTime(path); + } catch (IOException e) { + return null; + } + } + + // A file with only white space, or a byte order mark, has no content. + private static boolean hasContent(byte[] raw) { + String text = new String(raw, StandardCharsets.UTF_8); + for (int i = 0; i < text.length(); i++) { + char c = text.charAt(i); + if (!Character.isWhitespace(c) && c != '\uFEFF') { + return true; + } + } + return false; + } + // Adds an entry unless the key was already added. Reports whether it added the entry. private boolean add(Map> data, DataKind kind, String key, ItemDescriptor item) throws FileDataException { diff --git a/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/integrations/FileDataReloaderEmptiedFileTest.java b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/integrations/FileDataReloaderEmptiedFileTest.java new file mode 100644 index 00000000..01a66afd --- /dev/null +++ b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/integrations/FileDataReloaderEmptiedFileTest.java @@ -0,0 +1,466 @@ +package com.launchdarkly.sdk.server.integrations; + +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.integrations.FileDataReloader.EmptiedFileException; +import com.launchdarkly.sdk.server.integrations.FileDataSourceParsing.FileDataException; +import com.launchdarkly.sdk.server.integrations.OverrideFileLoader.LoadResult; +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.testhelpers.TempDir; + +import org.junit.After; +import org.junit.Test; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; + +import static com.launchdarkly.sdk.server.DataModel.FEATURES; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.hasItem; +import static org.hamcrest.Matchers.not; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +/** + * The reloader with the override files loader, on real files that are rewritten in place: a file + * whose content is in effect and is found empty is deferred until it has stayed empty for the retry + * delay. + */ +@SuppressWarnings("javadoc") +public class FileDataReloaderEmptiedFileTest { + // Long enough that a test finishes what it does while a file is empty before the retry reads the + // file again, also on a loaded machine. + private static final Duration LONG_RETRY = Duration.ofMillis(1500); + private static final long WAIT_MILLIS = 10000; + private static final String REREAD_LOG = "DEBUG:Reading flag data again after finding an empty file"; + + private final LogCapture logCapture = Logs.capture(); + private final LDLogger logger = LDLogger.withAdapter(logCapture, ""); + private final List reloaders = new ArrayList<>(); + + @After + public void closeReloaders() { + for (FileDataReloader r : reloaders) { + r.close(); + } + } + + /** + * Records the value of the flag "flag" in each applied snapshot, or "none" when a snapshot does + * not have it, and every reported failure. + */ + private static final class Recorder implements FileDataReloader.Handler { + final List applied = Collections.synchronizedList(new ArrayList<>()); + final List errors = Collections.synchronizedList(new ArrayList<>()); + + @Override + public void apply(LoadResult result) { + String value = "none"; + for (Map.Entry> kind : result.getData()) { + if (kind.getKey() != FEATURES) { + continue; + } + for (Map.Entry item : kind.getValue().getItems()) { + if (item.getKey().equals("flag")) { + value = LDValue.parse(FEATURES.serialize(item.getValue())).get("variations").get(0).stringValue(); + } + } + } + applied.add(value); + } + + @Override + public void onError(FileDataException e) { + errors.add(e); + } + } + + /** + * The override files loader, counting the loads that have finished. + */ + private static final class CountingLoader implements FileDataReloader.Loader { + final OverrideFileLoader loader; + final AtomicInteger finished = new AtomicInteger(); + + CountingLoader(Path... paths) { + loader = new OverrideFileLoader(Arrays.asList(paths), FileData.DuplicateKeysHandling.FAIL); + } + + @Override + public LoadResult load(Duration retryDelay) throws FileDataException, EmptiedFileException { + try { + return loader.load(retryDelay); + } finally { + finished.incrementAndGet(); + } + } + + @Override + public void accepted(LoadResult result) { + loader.accepted(result); + } + + void awaitFinished(int expected) throws InterruptedException { + long deadline = System.currentTimeMillis() + WAIT_MILLIS; + while (finished.get() < expected) { + if (System.currentTimeMillis() > deadline) { + throw new AssertionError("timed out waiting for load " + expected + ", got " + finished.get()); + } + Thread.sleep(5); + } + } + } + + private FileDataReloader reloader(CountingLoader loader, Recorder recorder, Duration retryDelay) { + FileDataReloader r = new FileDataReloader(loader, recorder, logger, Duration.ZERO, retryDelay, true); + reloaders.add(r); + return r; + } + + private static void awaitSize(List list, int expected) throws InterruptedException { + long deadline = System.currentTimeMillis() + WAIT_MILLIS; + while (list.size() < expected) { + if (System.currentTimeMillis() > deadline) { + throw new AssertionError("timed out waiting for size " + expected + ", got " + list); + } + Thread.sleep(5); + } + } + + private static Path write(TempDir dir, String name, String contents) throws Exception { + Path p = dir.getPath().resolve(name); + Files.write(p, contents.getBytes()); + return p; + } + + private static String yaml(String value) { + return "flagValues:\n flag: \"" + value + "\"\n"; + } + + private static String json(String value) { + return "{\"flagValues\":{\"flag\":\"" + value + "\"}}"; + } + + private static String readAgainLog(Path file) { + return "DEBUG:File " + file + " is empty, possibly while it is being rewritten; reading it again before applying it"; + } + + @Test + public void emptiedYamlFileIsReadAgainAndTheRewrittenContentIsApplied() throws Exception { + try (TempDir dir = TempDir.create()) { + Path file = write(dir, "overrides.yaml", yaml("a")); + CountingLoader loader = new CountingLoader(file); + Recorder recorder = new Recorder(); + FileDataReloader r = reloader(loader, recorder, LONG_RETRY); + r.reloadNow(); + + InPlaceWrites.rewrite(file, yaml("b"), () -> { + // The change signal of the truncation starts a load that finds the file empty. + r.trigger(); + loader.awaitFinished(2); + }); + r.trigger(); + awaitSize(recorder.applied, 2); + + assertEquals(Arrays.asList("a", "b"), recorder.applied); + assertTrue(recorder.errors.isEmpty()); + assertThat(logCapture.getMessageStrings(), hasItem(readAgainLog(file))); + } + } + + @Test + public void yamlFileThatStaysEmptyIsAppliedByTheRetry() throws Exception { + try (TempDir dir = TempDir.create()) { + Path file = write(dir, "overrides.yaml", yaml("a")); + CountingLoader loader = new CountingLoader(file); + Recorder recorder = new Recorder(); + FileDataReloader r = reloader(loader, recorder, LONG_RETRY); + r.reloadNow(); + + InPlaceWrites.truncate(file); + r.trigger(); + loader.awaitFinished(2); + assertEquals(Collections.singletonList("a"), recorder.applied); + + // No further signal arrives. The retry reads the file again and applies it. + awaitSize(recorder.applied, 2); + assertEquals(Arrays.asList("a", "none"), recorder.applied); + assertEquals(3, loader.finished.get()); + assertTrue(recorder.errors.isEmpty()); + assertThat(logCapture.getMessageStrings(), hasItem(REREAD_LOG)); + } + } + + @Test + public void emptiedJsonFileIsReportedOnlyByTheLoadAfterTheOneThatFindsItEmpty() throws Exception { + try (TempDir dir = TempDir.create()) { + Path file = write(dir, "overrides.json", json("a")); + CountingLoader loader = new CountingLoader(file); + Recorder recorder = new Recorder(); + FileDataReloader r = reloader(loader, recorder, LONG_RETRY); + r.reloadNow(); + + InPlaceWrites.truncate(file); + r.trigger(); + loader.awaitFinished(2); + assertTrue(recorder.errors.isEmpty()); + + awaitSize(recorder.errors, 1); + assertThat(FileDataReloader.describe(recorder.errors.get(0)), + containsString("the file has no content, which is not a JSON document")); + assertEquals(Collections.singletonList("a"), recorder.applied); + } + } + + @Test + public void furtherChangeSignalWithinTheGraceDoesNotApplyTheEmptyFile() throws Exception { + try (TempDir dir = TempDir.create()) { + Path file = write(dir, "overrides.yaml", yaml("a")); + CountingLoader loader = new CountingLoader(file); + Recorder recorder = new Recorder(); + FileDataReloader r = reloader(loader, recorder, LONG_RETRY); + r.reloadNow(); + + InPlaceWrites.truncate(file); + r.trigger(); + loader.awaitFinished(2); + r.trigger(); + loader.awaitFinished(3); + assertEquals(Collections.singletonList("a"), recorder.applied); + + // The retry armed by the second load finds that the file has stayed empty for the retry delay. + awaitSize(recorder.applied, 2); + assertEquals(Arrays.asList("a", "none"), recorder.applied); + assertEquals(4, loader.finished.get()); + assertThat(logCapture.getMessageStrings(), hasItem(REREAD_LOG)); + } + } + + @Test + public void secondRewriteWithinOneDebounceWindowIsDeferredToo() throws Exception { + // F1: the write of b and the next truncation happen before any load reads b. The next load + // finds the file empty again; the file is in a new rewrite, not one that stayed empty. + try (TempDir dir = TempDir.create()) { + Path file = write(dir, "overrides.yaml", yaml("a")); + CountingLoader loader = new CountingLoader(file); + Recorder recorder = new Recorder(); + FileDataReloader r = reloader(loader, recorder, LONG_RETRY); + r.reloadNow(); + + InPlaceWrites.rewrite(file, yaml("b"), () -> { + r.trigger(); + loader.awaitFinished(2); + }); + Thread.sleep(50); + InPlaceWrites.rewrite(file, yaml("c"), () -> { + r.trigger(); + loader.awaitFinished(3); + }); + r.trigger(); + awaitSize(recorder.applied, 2); + + assertEquals(Arrays.asList("a", "c"), recorder.applied); + } + } + + @Test + public void changeToAnotherFileWhileAFileIsEmptyDoesNotApplyTheEmptyFile() throws Exception { + // F2: a change to b starts a load while a is still in the middle of its rewrite. + try (TempDir dir = TempDir.create()) { + Path a = write(dir, "a.yaml", yaml("a")); + Path b = write(dir, "b.yaml", "flagValues:\n other: \"b1\"\n"); + CountingLoader loader = new CountingLoader(a, b); + Recorder recorder = new Recorder(); + FileDataReloader r = reloader(loader, recorder, LONG_RETRY); + r.reloadNow(); + + InPlaceWrites.rewrite(a, yaml("a2"), () -> { + r.trigger(); + loader.awaitFinished(2); + InPlaceWrites.rewrite(b, "flagValues:\n other: \"b2\"\n", () -> { }); + r.trigger(); + loader.awaitFinished(3); + }); + r.trigger(); + awaitSize(recorder.applied, 2); + + assertEquals(Arrays.asList("a", "a2"), recorder.applied); + } + } + + @Test + public void retryThatFindsANewTruncationIsDeferredToo() throws Exception { + // F3: the file is rewritten, and rewritten again before any load reads the first new content. + // The retry armed by the first deferral finds the second truncation. + try (TempDir dir = TempDir.create()) { + Path file = write(dir, "overrides.yaml", yaml("a")); + CountingLoader loader = new CountingLoader(file); + Recorder recorder = new Recorder(); + FileDataReloader r = reloader(loader, recorder, LONG_RETRY); + r.reloadNow(); + + InPlaceWrites.rewrite(file, yaml("b"), () -> { + r.trigger(); + loader.awaitFinished(2); + }); + Thread.sleep(50); + InPlaceWrites.rewrite(file, yaml("c"), () -> loader.awaitFinished(3)); + r.trigger(); + awaitSize(recorder.applied, 2); + + assertEquals(Arrays.asList("a", "c"), recorder.applied); + assertThat(logCapture.getMessageStrings(), hasItem(REREAD_LOG)); + } + } + + @Test + public void filesEmptiedTogetherAreAppliedByTheFirstRetry() throws Exception { + try (TempDir dir = TempDir.create()) { + Path a = write(dir, "a.yaml", yaml("a")); + Path b = write(dir, "b.yaml", "flagValues:\n other: \"b\"\n"); + CountingLoader loader = new CountingLoader(a, b); + Recorder recorder = new Recorder(); + FileDataReloader r = reloader(loader, recorder, LONG_RETRY); + r.reloadNow(); + + InPlaceWrites.truncate(a); + InPlaceWrites.truncate(b); + r.trigger(); + loader.awaitFinished(2); + awaitSize(recorder.applied, 2); + + assertEquals(Arrays.asList("a", "none"), recorder.applied); + assertEquals(3, loader.finished.get()); + assertThat(logCapture.getMessageStrings(), hasItem(readAgainLog(a))); + assertThat(logCapture.getMessageStrings(), hasItem(readAgainLog(b))); + } + } + + @Test + public void fileMovedToAnotherConfiguredPathIsDeferredWhenItIsRewritten() throws Exception { + // Moving a file from one configured path to another leaves the same content in effect, so the + // load is skipped as unchanged. It must still record that the content now comes from the new + // path, or an in-place rewrite of that path would be applied while it is empty. + try (TempDir dir = TempDir.create()) { + Path a = write(dir, "a.yaml", yaml("a")); + Path b = dir.getPath().resolve("b.yaml"); + CountingLoader loader = new CountingLoader(a, b); + Recorder recorder = new Recorder(); + FileDataReloader r = reloader(loader, recorder, LONG_RETRY); + r.reloadNow(); + + Files.move(a, b); + r.trigger(); + loader.awaitFinished(2); + assertEquals(Collections.singletonList("a"), recorder.applied); + + InPlaceWrites.rewrite(b, yaml("b"), () -> { + r.trigger(); + loader.awaitFinished(3); + }); + r.trigger(); + awaitSize(recorder.applied, 2); + + assertEquals(Arrays.asList("a", "b"), recorder.applied); + assertThat(logCapture.getMessageStrings(), hasItem(readAgainLog(b))); + } + } + + @Test + public void emptyPlaceholderFileDoesNotDelayAChangeToAnotherFile() throws Exception { + try (TempDir dir = TempDir.create()) { + Path placeholder = write(dir, "placeholder.yaml", ""); + Path data = write(dir, "data.json", json("a")); + CountingLoader loader = new CountingLoader(placeholder, data); + Recorder recorder = new Recorder(); + FileDataReloader r = reloader(loader, recorder, LONG_RETRY); + r.reloadNow(); + + InPlaceWrites.rewrite(data, json("b"), () -> { }); + r.trigger(); + awaitSize(recorder.applied, 2); + + // The load of the signal applied the change, without waiting for a retry. + assertEquals(Arrays.asList("a", "b"), recorder.applied); + assertEquals(2, loader.finished.get()); + assertThat(logCapture.getMessageStrings(), not(hasItem(readAgainLog(placeholder)))); + } + } + + @Test + public void fileThatIsDeletedAndCreatedEmptyIsAppliedRightAway() throws Exception { + try (TempDir dir = TempDir.create()) { + Path file = write(dir, "overrides.yaml", yaml("a")); + CountingLoader loader = new CountingLoader(file); + Recorder recorder = new Recorder(); + FileDataReloader r = reloader(loader, recorder, LONG_RETRY); + r.reloadNow(); + + Files.delete(file); + r.trigger(); + loader.awaitFinished(2); + Files.write(file, new byte[0]); + r.trigger(); + awaitSize(recorder.applied, 3); + + assertEquals(Arrays.asList("a", "none", "none"), recorder.applied); + assertEquals(3, loader.finished.get()); + assertThat(logCapture.getMessageStrings(), not(hasItem(readAgainLog(file)))); + } + } + + @Test + public void retryLeftOverFromAFailureThatFindsTheFileEmptiedReadsItAgain() throws Exception { + try (TempDir dir = TempDir.create()) { + Path file = write(dir, "overrides.yaml", yaml("a")); + CountingLoader loader = new CountingLoader(file); + Recorder recorder = new Recorder(); + FileDataReloader r = reloader(loader, recorder, LONG_RETRY); + r.reloadNow(); + + // A load fails and arms the retry. + InPlaceWrites.rewrite(file, "flagValues: [", () -> { }); + r.trigger(); + awaitSize(recorder.errors, 1); + + // The file is rewritten again, and only the retry reads it while it is empty. + InPlaceWrites.rewrite(file, yaml("b"), () -> loader.awaitFinished(3)); + r.trigger(); + awaitSize(recorder.applied, 2); + + assertEquals(Arrays.asList("a", "b"), recorder.applied); + assertThat(logCapture.getMessageStrings(), hasItem(readAgainLog(file))); + } + } + + @Test + public void withoutRetriesAnEmptiedFileIsAppliedRightAway() throws Exception { + try (TempDir dir = TempDir.create()) { + Path file = write(dir, "overrides.yaml", yaml("a")); + CountingLoader loader = new CountingLoader(file); + Recorder recorder = new Recorder(); + FileDataReloader r = reloader(loader, recorder, Duration.ZERO); + r.reloadNow(); + + InPlaceWrites.truncate(file); + r.trigger(); + awaitSize(recorder.applied, 2); + + assertEquals(Arrays.asList("a", "none"), recorder.applied); + assertEquals(2, loader.finished.get()); + assertThat(logCapture.getMessageStrings(), not(hasItem(readAgainLog(file)))); + } + } +} diff --git a/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/integrations/FileDataReloaderTest.java b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/integrations/FileDataReloaderTest.java index 017906bc..63280805 100644 --- a/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/integrations/FileDataReloaderTest.java +++ b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/integrations/FileDataReloaderTest.java @@ -3,14 +3,18 @@ import com.launchdarkly.logging.LDLogger; import com.launchdarkly.logging.LogCapture; import com.launchdarkly.logging.Logs; +import com.launchdarkly.sdk.server.integrations.FileDataReloader.EmptiedFileException; import com.launchdarkly.sdk.server.integrations.OverrideFileLoader.LoadResult; import com.launchdarkly.sdk.server.integrations.FileDataSourceParsing.FileDataException; import org.junit.After; import org.junit.Test; +import java.nio.file.Path; +import java.nio.file.Paths; import java.time.Duration; import java.util.ArrayList; +import java.util.Arrays; import java.util.Collections; import java.util.List; import java.util.concurrent.CountDownLatch; @@ -18,7 +22,6 @@ import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicReference; -import java.util.function.Supplier; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.equalTo; @@ -26,6 +29,7 @@ import static org.hamcrest.Matchers.hasItem; import static org.hamcrest.Matchers.instanceOf; import static org.hamcrest.Matchers.lessThan; +import static org.hamcrest.Matchers.not; import static org.hamcrest.Matchers.startsWith; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; @@ -63,14 +67,24 @@ private static FileDataException failure(String message) { return new FileDataException(message, null, null); } + /** + * One step of a scripted loader: it returns a result or throws. + */ + private interface Step { + LoadResult run() throws FileDataException, EmptiedFileException; + } + /** * A scripted loader: each call takes the next step from the queue. A step either returns a result - * or throws. When the queue is empty the last step repeats. + * or throws. When the queue is empty the last step repeats. It records the argument of each call + * and every accepted result. */ private static final class ScriptedLoader implements FileDataReloader.Loader { final AtomicInteger calls = new AtomicInteger(); - final LinkedBlockingQueue> steps = new LinkedBlockingQueue<>(); - volatile Supplier last; + final List retryDelayArguments = Collections.synchronizedList(new ArrayList<>()); + final List accepted = Collections.synchronizedList(new ArrayList<>()); + final LinkedBlockingQueue steps = new LinkedBlockingQueue<>(); + volatile Step last; volatile CountDownLatch entered; volatile CountDownLatch release; @@ -81,7 +95,7 @@ ScriptedLoader then(LoadResult result) { ScriptedLoader thenFail(String message) { steps.add(() -> { - throw new UncheckedFileDataException(failure(message)); + throw failure(message); }); return this; } @@ -93,12 +107,20 @@ ScriptedLoader thenThrow(RuntimeException e) { return this; } + ScriptedLoader thenEmptied(Path path) { + steps.add(() -> { + throw new EmptiedFileException(Collections.singletonList(path)); + }); + return this; + } + @Override - public LoadResult load() throws FileDataException { + public LoadResult load(Duration retryDelay) throws FileDataException, EmptiedFileException { // Both latches are read before the entered signal, so a test that clears them after the // signal cannot change what this call does. CountDownLatch enteredLatch = entered; CountDownLatch releaseLatch = release; + retryDelayArguments.add(retryDelay); calls.incrementAndGet(); if (enteredLatch != null) { enteredLatch.countDown(); @@ -110,26 +132,18 @@ public LoadResult load() throws FileDataException { Thread.currentThread().interrupt(); } } - Supplier step = steps.poll(); + Step step = steps.poll(); if (step == null) { step = last; } else { last = step; } - try { - return step.get(); - } catch (UncheckedFileDataException e) { - throw e.wrapped; - } + return step.run(); } - } - - @SuppressWarnings("serial") - private static final class UncheckedFileDataException extends RuntimeException { - final FileDataException wrapped; - UncheckedFileDataException(FileDataException wrapped) { - this.wrapped = wrapped; + @Override + public void accepted(LoadResult result) { + accepted.add(result); } } @@ -539,4 +553,112 @@ public void unexpectedRuntimeExceptionFromLoaderIsLoggedAndWorkerSurvives() thro assertThat(logCapture.getMessageStrings(), hasItem(startsWith("ERROR:Unexpected error while reloading flag data:"))); } + + private static final Path EMPTIED = Paths.get("/overrides/emptied.yaml"); + private static final String READ_AGAIN_MESSAGE = "DEBUG:File " + EMPTIED + + " is empty, possibly while it is being rewritten; reading it again before applying it"; + + @Test + public void emptiedFileIsReadAgainAfterTheRetryDelayWithoutReportingAFailure() throws Exception { + LoadResult first = resultWithHash(1); + LoadResult rewritten = resultWithHash(2); + ScriptedLoader loader = new ScriptedLoader().then(first).thenEmptied(EMPTIED).then(rewritten); + RecordingHandler handler = new RecordingHandler(); + FileDataReloader r = reloader(loader, handler, Duration.ZERO, SHORT, true); + r.reloadNow(); + + // The load that finds the file emptied applies nothing. Only the retry, with no further + // trigger, loads again. + r.trigger(); + awaitSize(handler.applied, 2); + + assertEquals(first, handler.applied.get(0)); + assertEquals(rewritten, handler.applied.get(1)); + assertEquals(3, loader.calls.get()); + assertTrue(handler.errors.isEmpty()); + List messages = logCapture.getMessageStrings(); + assertThat(messages, hasItem(READ_AGAIN_MESSAGE)); + assertThat(messages, hasItem("DEBUG:Reading flag data again after finding an empty file")); + assertThat(messages, not(hasItem("DEBUG:Retrying flag data load after earlier failure"))); + for (String message : messages) { + assertFalse(message, message.startsWith("ERROR:")); + } + } + + @Test + public void loaderIsGivenTheRetryDelayOrZeroWithoutRetries() { + ScriptedLoader withRetries = new ScriptedLoader().then(resultWithHash(1)); + reloader(withRetries, new RecordingHandler(), Duration.ZERO, SHORT, true).reloadNow(); + assertEquals(Collections.singletonList(SHORT), withRetries.retryDelayArguments); + + // Without retries nothing would read a deferred file again, so the loader must not defer one. + ScriptedLoader withoutRetries = new ScriptedLoader().then(resultWithHash(1)); + reloader(withoutRetries, new RecordingHandler(), Duration.ZERO, Duration.ofMillis(-1), true).reloadNow(); + assertEquals(Collections.singletonList(Duration.ZERO), withoutRetries.retryDelayArguments); + } + + @Test + public void loaderIsToldOfEveryResultThatBecomesTheDataInEffect() throws Exception { + // An applied result and a result skipped as unchanged are the data in effect. A failure, a + // deferral and a result that the consumer rejects are not. + LoadResult first = resultWithHash(1); + LoadResult unchanged = resultWithHash(1); + LoadResult rejected = resultWithHash(2); + LoadResult last = resultWithHash(3); + ScriptedLoader loader = new ScriptedLoader().then(first).then(unchanged).thenFail("broken") + .thenEmptied(EMPTIED).then(rejected).then(last); + RecordingHandler handler = new RecordingHandler() { + @Override + public void apply(LoadResult r) { + if (r == rejected) { + throw new IllegalStateException("consumer failed"); + } + super.apply(r); + } + }; + FileDataReloader r = reloader(loader, handler, Duration.ZERO, Duration.ZERO, true); + r.reloadNow(); + for (int i = 2; i <= 6; i++) { + r.trigger(); + awaitAtLeast(loader.calls, i); + } + awaitSize(loader.accepted, 3); + + assertEquals(Arrays.asList(first, unchanged, last), loader.accepted); + assertEquals(Arrays.asList(first, last), handler.applied); + } + + @Test + public void failureAfterAReadAgainIsRetriedAsAFailure() throws Exception { + ScriptedLoader loader = new ScriptedLoader().then(resultWithHash(1)).thenEmptied(EMPTIED).thenFail("broken") + .then(resultWithHash(2)); + RecordingHandler handler = new RecordingHandler(); + FileDataReloader r = reloader(loader, handler, Duration.ZERO, SHORT, true); + r.reloadNow(); + + r.trigger(); + awaitSize(handler.applied, 2); + assertEquals(4, loader.calls.get()); + assertEquals(1, handler.errors.size()); + List messages = logCapture.getMessageStrings(); + assertThat(messages, hasItem("DEBUG:Reading flag data again after finding an empty file")); + assertThat(messages, hasItem("DEBUG:Retrying flag data load after earlier failure")); + } + + @Test + public void readingAnEmptiedFileAgainDoesNotEndARecovery() throws Exception { + // After a failure, the next success applies even when its content is unchanged. A read again + // in between is not that success. + LoadResult result = resultWithHash(1); + ScriptedLoader loader = new ScriptedLoader().then(result).thenFail("broken").thenEmptied(EMPTIED).then(result); + RecordingHandler handler = new RecordingHandler(); + FileDataReloader r = reloader(loader, handler, Duration.ZERO, SHORT, true); + r.reloadNow(); + + r.trigger(); + awaitSize(handler.applied, 2); + assertEquals(4, loader.calls.get()); + assertEquals(1, handler.errors.size()); + assertThat(logCapture.getMessageStrings(), hasItem(READ_AGAIN_MESSAGE)); + } } diff --git a/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/integrations/FileOverrideSourceInPlaceRewriteTest.java b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/integrations/FileOverrideSourceInPlaceRewriteTest.java new file mode 100644 index 00000000..39898115 --- /dev/null +++ b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/integrations/FileOverrideSourceInPlaceRewriteTest.java @@ -0,0 +1,261 @@ +package com.launchdarkly.sdk.server.integrations; + +import com.launchdarkly.logging.LDLogLevel; +import com.launchdarkly.logging.LogCapture; +import com.launchdarkly.logging.Logs; +import com.launchdarkly.sdk.LDContext; +import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.server.Components; +import com.launchdarkly.sdk.server.LDClient; +import com.launchdarkly.sdk.server.LDConfig; +import com.launchdarkly.sdk.server.TestComponents; +import com.launchdarkly.sdk.server.integrations.FileOverrides.ChangeDetection; +import com.launchdarkly.sdk.server.subsystems.ClientContext; +import com.launchdarkly.sdk.server.subsystems.DataStoreTypes.ItemDescriptor; +import com.launchdarkly.sdk.server.subsystems.OverrideSource; +import com.launchdarkly.testhelpers.TempDir; + +import org.junit.After; +import org.junit.Test; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; + +import static com.launchdarkly.sdk.server.DataModel.FEATURES; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +/** + * The file-based override source while its file is rewritten in place: the file is truncated, stays + * empty for a while, and then gets the new content. The overrides in effect must go from the old + * content to the new content without a moment in between without the override. + */ +@SuppressWarnings("javadoc") +public class FileOverrideSourceInPlaceRewriteTest { + private static final long WAIT_MILLIS = 10000; + private static final LDContext CONTEXT = LDContext.create("user-key"); + + // A watching source reads the file within the debounce delay of the truncation. The retry delay + // leaves a margin over the pause for a loaded machine. + private static final Duration WATCHING_PAUSE = Duration.ofMillis(300); + private static final Duration WATCHING_RETRY_DELAY = Duration.ofMillis(1500); + + // A polling source examines the file once per second. A pause longer than that makes sure that one + // examination finds the file empty, and the retry delay is longer than the pause by a margin. + private static final Duration POLL_INTERVAL = Duration.ofSeconds(1); + private static final Duration POLLING_PAUSE = Duration.ofMillis(1200); + private static final Duration POLLING_RETRY_DELAY = Duration.ofMillis(2500); + + private final LogCapture logCapture = Logs.capture(); + private final ClientContext context = TestComponents.clientContext("sdk-key", + new LDConfig.Builder().logging(Components.logging(logCapture).level(LDLogLevel.DEBUG)).build()); + private final List closeables = new ArrayList<>(); + + @After + public void close() throws Exception { + for (AutoCloseable c : closeables) { + c.close(); + } + } + + private static String doc(Path file, String value) { + return file.toString().endsWith(".yaml") ? "flagValues:\n flag: \"" + value + "\"\n" + : "{\"flagValues\":{\"flag\":\"" + value + "\"}}"; + } + + private static Path write(TempDir dir, String name, String value) throws Exception { + Path p = dir.getPath().resolve(name); + Files.write(p, doc(p, value).getBytes(StandardCharsets.UTF_8)); + return p; + } + + private static FileOverrideSourceImpl source(Path file, ChangeDetection mode, Duration retryDelay, + ClientContext context) { + return new FileOverrideSourceImpl(Collections.singletonList(file.toAbsolutePath()), + FileData.DuplicateKeysHandling.FAIL, mode, POLL_INTERVAL, retryDelay, + context.getBaseLogger().subLogger("FileOverrideSource"), FileDataWatcher::create); + } + + // The value of the override "flag" in a snapshot, or "none" when the snapshot does not have it. + private static String flagValue(Map> snapshot) { + ItemDescriptor item = snapshot.get(FEATURES.getName()).get("flag"); + if (item == null) { + return "none"; + } + return LDValue.parse(FEATURES.serialize(item)).get("variations").get(0).stringValue(); + } + + /** + * The writes a test makes to the file once the source has started. + */ + private interface Rewrites { + void run() throws Exception; + } + + // Starts the source on a file whose override has the value "a", makes the writes, and returns the + // value of the override in every snapshot the source supplies until one has the final value, and + // in any snapshot shortly after that. + private List appliedThroughRewrites(Path file, OverrideSource source, Rewrites rewrites, + String finalValue) throws Exception { + FileOverrideSourceTest.RecordingSink sink = new FileOverrideSourceTest.RecordingSink(); + closeables.add(source); + source.start(sink); + List applied = new ArrayList<>(); + applied.add(flagValue(sink.await())); + // Let the start of change detection settle. + Thread.sleep(300); + + rewrites.run(); + + long deadline = System.currentTimeMillis() + WAIT_MILLIS; + while (!applied.get(applied.size() - 1).equals(finalValue) && System.currentTimeMillis() < deadline) { + Map> snapshot = sink.snapshots.poll(100, TimeUnit.MILLISECONDS); + if (snapshot != null) { + applied.add(flagValue(snapshot)); + } + } + // A later snapshot would be wrong too. + Map> late = sink.snapshots.poll(500, TimeUnit.MILLISECONDS); + if (late != null) { + applied.add(flagValue(late)); + } + return applied; + } + + private void sourceKeepsTheOverrideThroughARewrite(String name, ChangeDetection mode, Duration pause, + Duration retryDelay) throws Exception { + try (TempDir dir = TempDir.create()) { + Path file = write(dir, name, "a"); + List applied = appliedThroughRewrites(file, source(file, mode, retryDelay, context), + () -> InPlaceWrites.rewrite(file, doc(file, "b"), pause), "b"); + assertEquals(Arrays.asList("a", "b"), applied); + } + } + + // The override must have a value in every snapshot, and the last one must be the final value. An + // intermediate value may or may not be applied. + private static void assertNeverWithoutTheOverride(List applied, String finalValue) { + assertEquals(applied.toString(), "a", applied.get(0)); + assertEquals(applied.toString(), finalValue, applied.get(applied.size() - 1)); + assertTrue(applied.toString(), !applied.contains("none")); + } + + @Test + public void watchingSourceKeepsTheOverrideThroughARewriteOfAJsonFile() throws Exception { + sourceKeepsTheOverrideThroughARewrite("overrides.json", ChangeDetection.WATCHING, WATCHING_PAUSE, + WATCHING_RETRY_DELAY); + } + + @Test + public void watchingSourceKeepsTheOverrideThroughARewriteOfAYamlFile() throws Exception { + sourceKeepsTheOverrideThroughARewrite("overrides.yaml", ChangeDetection.WATCHING, WATCHING_PAUSE, + WATCHING_RETRY_DELAY); + } + + @Test + public void pollingSourceKeepsTheOverrideThroughARewriteOfAJsonFile() throws Exception { + sourceKeepsTheOverrideThroughARewrite("overrides.json", ChangeDetection.POLLING, POLLING_PAUSE, + POLLING_RETRY_DELAY); + } + + @Test + public void pollingSourceKeepsTheOverrideThroughARewriteOfAYamlFile() throws Exception { + sourceKeepsTheOverrideThroughARewrite("overrides.yaml", ChangeDetection.POLLING, POLLING_PAUSE, + POLLING_RETRY_DELAY); + } + + @Test + public void watchingSourceKeepsTheOverrideThroughTwoQuickRewrites() throws Exception { + // The write of b and the second truncation fall in one debounce window, so no load reads b. + try (TempDir dir = TempDir.create()) { + Path file = write(dir, "overrides.yaml", "a"); + List applied = appliedThroughRewrites(file, + source(file, ChangeDetection.WATCHING, WATCHING_RETRY_DELAY, context), () -> { + InPlaceWrites.rewrite(file, doc(file, "b"), Duration.ofMillis(150)); + InPlaceWrites.rewrite(file, doc(file, "c"), WATCHING_PAUSE); + }, "c"); + assertNeverWithoutTheOverride(applied, "c"); + } + } + + @Test + public void defaultSourceKeepsTheOverrideThroughRewritesASecondApart() throws Exception { + // The default source polls once per second and retries after one second. Two rewrites about a + // second apart can let a poll or a retry find the second truncation. + try (TempDir dir = TempDir.create()) { + Path file = write(dir, "overrides.yaml", "a"); + List applied = appliedThroughRewrites(file, FileOverrides.source().filePaths(file).build(context), + () -> { + InPlaceWrites.rewrite(file, doc(file, "b"), WATCHING_PAUSE); + Thread.sleep(700); + InPlaceWrites.rewrite(file, doc(file, "c"), WATCHING_PAUSE); + }, "c"); + assertNeverWithoutTheOverride(applied, "c"); + } + } + + private void clientServesTheOverrideThroughARewrite(String name) throws Exception { + try (TempDir dir = TempDir.create()) { + Path file = write(dir, name, "a"); + TestDataV2 td = TestDataV2.synchronizer(); + td.update(td.flag("flag").valueForAll(LDValue.of("launchdarkly-value"))); + LDConfig config = new LDConfig.Builder() + .dataSystem(Components.dataSystem().custom().synchronizers(td) + .overrides(ctx -> source(file, ChangeDetection.WATCHING, WATCHING_RETRY_DELAY, ctx))) + .events(Components.noEvents()) + .logging(Components.logging(logCapture).level(LDLogLevel.DEBUG)) + .build(); + try (LDClient client = new LDClient("sdk-key", config)) { + // The distinct values the client serves, in order. + List served = Collections.synchronizedList(new ArrayList<>()); + AtomicBoolean stop = new AtomicBoolean(false); + Thread sampler = new Thread(() -> { + while (!stop.get()) { + String value = client.stringVariation("flag", CONTEXT, "default"); + if (served.isEmpty() || !served.get(served.size() - 1).equals(value)) { + served.add(value); + } + try { + Thread.sleep(1); + } catch (InterruptedException e) { + return; + } + } + }); + sampler.start(); + try { + Thread.sleep(300); + InPlaceWrites.rewrite(file, doc(file, "b"), WATCHING_PAUSE); + long deadline = System.currentTimeMillis() + WAIT_MILLIS; + while (!served.contains("b") && System.currentTimeMillis() < deadline) { + Thread.sleep(10); + } + Thread.sleep(500); + } finally { + stop.set(true); + sampler.join(); + } + assertEquals(Arrays.asList("a", "b"), served); + } + } + } + + @Test + public void clientServesTheOverrideThroughARewriteOfAJsonFile() throws Exception { + clientServesTheOverrideThroughARewrite("overrides.json"); + } + + @Test + public void clientServesTheOverrideThroughARewriteOfAYamlFile() throws Exception { + clientServesTheOverrideThroughARewrite("overrides.yaml"); + } +} diff --git a/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/integrations/FileOverrideSourceTest.java b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/integrations/FileOverrideSourceTest.java index 0a3ebb74..de95ee6e 100644 --- a/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/integrations/FileOverrideSourceTest.java +++ b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/integrations/FileOverrideSourceTest.java @@ -322,6 +322,35 @@ public void emptyDocumentLogsNoEntries() throws Exception { } } + @Test + public void emptyYamlFileAtStartupHasNoEntries() throws Exception { + for (String contents : new String[] { "", " \n\n" }) { + try (TempDir dir = TempDir.create()) { + Path file = write(dir, "overrides.yaml", contents); + RecordingSink sink = new RecordingSink(); + start(FileOverrides.source().filePaths(file), sink); + assertTrue(sink.await().get(FEATURES.getName()).isEmpty()); + assertThat(logCapture.getMessageStrings(), hasItem("INFO:Flag overrides: none in effect (" + file + ": no entries)")); + } + } + } + + @Test + public void emptyJsonFileAtStartupFailsToLoad() throws Exception { + for (String name : new String[] { "overrides.json", "overrides" }) { + for (String contents : new String[] { "", " \n\t\n" }) { + try (TempDir dir = TempDir.create()) { + Path file = write(dir, name, contents); + RecordingSink sink = new RecordingSink(); + start(FileOverrides.source().filePaths(file), sink); + sink.assertNoSnapshot(200); + assertThat(logCapture.getMessageStrings(), hasItem("ERROR:Unable to load flags: file " + file + + ": the file has no content, which is not a JSON document")); + } + } + } + } + @Test public void malformedFileAtStartupSuppliesNothingAndLogsError() throws Exception { try (TempDir dir = TempDir.create()) { @@ -381,7 +410,8 @@ public void watchingFallsBackToPollingWhenTheWatcherCannotBeCreated() throws Exc Path file = write(dir, "overrides.json", docWith("flag", "first")); FileOverrideSourceImpl source = new FileOverrideSourceImpl( Collections.singletonList(file.toAbsolutePath()), FileData.DuplicateKeysHandling.FAIL, - ChangeDetection.WATCHING, Duration.ofMillis(10), context.getBaseLogger().subLogger("FileOverrideSource"), + ChangeDetection.WATCHING, Duration.ofMillis(10), FileDataReloader.DEFAULT_RETRY_DELAY, + context.getBaseLogger().subLogger("FileOverrideSource"), (paths, logger) -> { throw new IOException("watch service unavailable"); }); diff --git a/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/integrations/InPlaceWrites.java b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/integrations/InPlaceWrites.java new file mode 100644 index 00000000..69273d79 --- /dev/null +++ b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/integrations/InPlaceWrites.java @@ -0,0 +1,49 @@ +package com.launchdarkly.sdk.server.integrations; + +import java.io.OutputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; + +/** + * Rewrites files in place the way many applications and editors save them: the file is truncated + * when it is opened for writing, and the new content is written through the same open stream. A + * reader in between finds the file empty. The tests use these writes, not an atomic replacement, so + * that the code under test must handle a file that is being rewritten. + */ +@SuppressWarnings("javadoc") +final class InPlaceWrites { + /** + * Work done while the file is empty, between the truncation and the write. + */ + interface WhileEmpty { + void run() throws Exception; + } + + private InPlaceWrites() {} + + /** + * Truncates the file, pauses, and then writes the new content. + */ + static void rewrite(Path path, String content, Duration pause) throws Exception { + rewrite(path, content, () -> Thread.sleep(pause.toMillis())); + } + + /** + * Truncates the file, runs the given work while the file is empty, and then writes the new content. + */ + static void rewrite(Path path, String content, WhileEmpty whileEmpty) throws Exception { + try (OutputStream out = Files.newOutputStream(path)) { + whileEmpty.run(); + out.write(content.getBytes(StandardCharsets.UTF_8)); + } + } + + /** + * Truncates the file and leaves it empty. + */ + static void truncate(Path path) throws Exception { + rewrite(path, "", () -> { }); + } +} diff --git a/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/integrations/OverrideFileLoaderTest.java b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/integrations/OverrideFileLoaderTest.java index 2c00651c..03b285dc 100644 --- a/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/integrations/OverrideFileLoaderTest.java +++ b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/integrations/OverrideFileLoaderTest.java @@ -1,6 +1,7 @@ package com.launchdarkly.sdk.server.integrations; import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.server.integrations.FileDataReloader.EmptiedFileException; import com.launchdarkly.sdk.server.integrations.FileDataSourceParsing.FileDataException; import com.launchdarkly.sdk.server.integrations.OverrideFileLoader.FileSummary; import com.launchdarkly.sdk.server.integrations.OverrideFileLoader.LoadResult; @@ -14,11 +15,13 @@ import java.nio.file.Files; import java.nio.file.Path; +import java.time.Duration; import java.util.Arrays; import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.concurrent.atomic.AtomicLong; import static com.launchdarkly.sdk.server.DataModel.FEATURES; import static com.launchdarkly.sdk.server.DataModel.SEGMENTS; @@ -69,7 +72,7 @@ public void loadsFlagsFlagValuesAndSegmentsKeepingDocumentVersions() throws Exce "{\"flags\":{\"f1\":{\"key\":\"f1\",\"version\":7,\"on\":true,\"variations\":[true],\"fallthrough\":{\"variation\":0}}}," + "\"flagValues\":{\"f2\":\"x\"}," + "\"segments\":{\"s1\":{\"key\":\"s1\",\"version\":9}}}"); - LoadResult result = loader(file).load(); + LoadResult result = loader(file).load(Duration.ZERO); Map> data = toMap(result); assertEquals(7, data.get(FEATURES).get("f1").getVersion()); @@ -90,7 +93,7 @@ public void loadsFlagsFlagValuesAndSegmentsKeepingDocumentVersions() throws Exce public void valueOnlyEntryBecomesFallthroughFlagServingTheValue() throws Exception { try (TempDir dir = TempDir.create()) { Path file = write(dir, "overrides.json", "{\"flagValues\":{\"f2\":\"x\"}}"); - LoadResult result = loader(file).load(); + LoadResult result = loader(file).load(Duration.ZERO); ItemDescriptor f2 = toMap(result).get(FEATURES).get("f2"); assertEquals(0, f2.getVersion()); LDValue flag = json(FEATURES, f2); @@ -104,10 +107,10 @@ public void valueOnlyEntryBecomesFallthroughFlagServingTheValue() throws Excepti } @Test - public void yamlIsAutoDetected() throws Exception { + public void yamlFileIsParsedAsYaml() throws Exception { try (TempDir dir = TempDir.create()) { Path file = write(dir, "overrides.yaml", "flagValues:\n yaml-flag: \"override-value\"\n"); - LoadResult result = loader(file).load(); + LoadResult result = loader(file).load(Duration.ZERO); assertEquals(LDValue.buildArray().add("override-value").build(), json(FEATURES, toMap(result).get(FEATURES).get("yaml-flag")).get("variations")); } @@ -119,7 +122,7 @@ public void missingFileContributesNothing() throws Exception { Path present = write(dir, "present.json", "{\"flagValues\":{\"flag\":\"value\"}}"); Path missing = dir.getPath().resolve("missing.json"); Path segments = write(dir, "segments.json", "{\"segments\":{\"s1\":{\"key\":\"s1\"}}}"); - LoadResult result = loader(present, missing, segments).load(); + LoadResult result = loader(present, missing, segments).load(Duration.ZERO); Map> data = toMap(result); assertEquals(1, data.get(FEATURES).size()); @@ -139,7 +142,7 @@ public void missingFileContributesNothing() throws Exception { @Test public void allFilesMissingProducesEmptyResult() throws Exception { try (TempDir dir = TempDir.create()) { - LoadResult result = loader(dir.getPath().resolve("a.json"), dir.getPath().resolve("b.json")).load(); + LoadResult result = loader(dir.getPath().resolve("a.json"), dir.getPath().resolve("b.json")).load(Duration.ZERO); assertEquals(0, result.getFlagCount()); assertEquals(0, result.getSegmentCount()); assertFalse(result.getData().iterator().hasNext()); @@ -154,7 +157,7 @@ public void unreadableFileFailsTheLoad() throws Exception { Path directory = dir.getPath().resolve("dir.json"); Files.createDirectory(directory); try { - loader(directory).load(); + loader(directory).load(Duration.ZERO); fail("expected exception"); } catch (FileDataException e) { assertThat(e.getMessage(), containsString(directory.toString())); @@ -170,7 +173,7 @@ public void malformedDocumentFailsTheLoadWithFileAttribution() throws Exception Path good = write(dir, "good.json", "{\"flagValues\":{\"a\":1}}"); Path bad = write(dir, "bad.json", "{\"flagValues\""); try { - loader(good, bad).load(); + loader(good, bad).load(Duration.ZERO); fail("expected exception"); } catch (FileDataException e) { assertThat(e.getMessage(), containsString(bad.toString())); @@ -185,7 +188,7 @@ public void modelRejectedDocumentIsAFileDataError() throws Exception { try (TempDir dir = TempDir.create()) { Path file = write(dir, "bad-model.json", "{\"flags\":{\"f1\":{\"key\":\"f1\",\"rules\":5}}}"); try { - loader(file).load(); + loader(file).load(Duration.ZERO); fail("expected exception"); } catch (FileDataException e) { assertThat(e.getMessage(), containsString(file.toString())); @@ -201,7 +204,7 @@ public void duplicateKeysFailByDefaultAcrossFilesAndWithinAFile() throws Excepti Path a = write(dir, "a.json", "{\"flagValues\":{\"flag\":\"a\"}}"); Path b = write(dir, "b.json", "{\"flagValues\":{\"flag\":\"b\"}}"); try { - loader(a, b).load(); + loader(a, b).load(Duration.ZERO); fail("expected exception"); } catch (FileDataException e) { assertThat(e.getMessage(), containsString("in features, key \"flag\" was already defined")); @@ -211,7 +214,7 @@ public void duplicateKeysFailByDefaultAcrossFilesAndWithinAFile() throws Excepti Path both = write(dir, "both.json", "{\"flags\":{\"x\":{\"key\":\"x\"}},\"flagValues\":{\"x\":true}}"); try { - loader(both).load(); + loader(both).load(Duration.ZERO); fail("expected exception"); } catch (FileDataException e) { assertThat(e.getMessage(), containsString("key \"x\" was already defined")); @@ -220,7 +223,7 @@ public void duplicateKeysFailByDefaultAcrossFilesAndWithinAFile() throws Excepti Path seg1 = write(dir, "seg1.json", "{\"segments\":{\"s\":{\"key\":\"s\"}}}"); Path seg2 = write(dir, "seg2.json", "{\"segments\":{\"s\":{\"key\":\"s\"}}}"); try { - loader(seg1, seg2).load(); + loader(seg1, seg2).load(Duration.ZERO); fail("expected exception"); } catch (FileDataException e) { assertThat(e.getMessage(), containsString("in segments, key \"s\" was already defined")); @@ -234,7 +237,7 @@ public void ignoreHandlingKeepsFirstFileAndCountsOnlyKeptEntries() throws Except Path first = write(dir, "first.json", "{\"flagValues\":{\"shared\":\"first\"}}"); Path second = write(dir, "second.json", "{\"flagValues\":{\"shared\":\"second\",\"only-second\":\"x\"}}"); OverrideFileLoader loader = new OverrideFileLoader(Arrays.asList(first, second), FileData.DuplicateKeysHandling.IGNORE); - LoadResult result = loader.load(); + LoadResult result = loader.load(Duration.ZERO); Map flags = toMap(result).get(FEATURES); assertEquals(LDValue.buildArray().add("first").build(), json(FEATURES, flags.get("shared")).get("variations")); @@ -251,17 +254,17 @@ public void contentHashReflectsRawContentOfPresentFiles() throws Exception { Path file = write(dir, "overrides.json", "{\"flagValues\":{\"a\":1}}"); Path missing = dir.getPath().resolve("missing.json"); OverrideFileLoader loader = loader(file, missing); - byte[] first = loader.load().getContentHash(); - byte[] second = loader.load().getContentHash(); + byte[] first = loader.load(Duration.ZERO).getContentHash(); + byte[] second = loader.load(Duration.ZERO).getContentHash(); assertArrayEquals(first, second); Files.write(file, "{\"flagValues\":{\"a\":2}}".getBytes()); - byte[] third = loader.load().getContentHash(); + byte[] third = loader.load(Duration.ZERO).getContentHash(); assertThat(Arrays.equals(first, third), is(false)); // A file that appears changes the digest. Files.write(missing, "{}".getBytes()); - byte[] fourth = loader.load().getContentHash(); + byte[] fourth = loader.load(Duration.ZERO).getContentHash(); assertThat(Arrays.equals(third, fourth), is(false)); } } @@ -270,7 +273,7 @@ public void contentHashReflectsRawContentOfPresentFiles() throws Exception { public void emptyDocumentContributesNothing() throws Exception { try (TempDir dir = TempDir.create()) { Path file = write(dir, "empty.json", "{}"); - LoadResult result = loader(file).load(); + LoadResult result = loader(file).load(Duration.ZERO); assertEquals(0, result.getFlagCount()); assertTrue(result.getFiles().get(0).isPresent()); assertThat(result.getFiles().get(0).getFlags(), equalTo(0)); @@ -284,8 +287,365 @@ public void loaderReadsFilesInConfiguredOrder() throws Exception { Path b = write(dir, "b.json", "{\"flagValues\":{\"k\":\"b\"}}"); OverrideFileLoader loader = new OverrideFileLoader(Arrays.asList(b, a), FileData.DuplicateKeysHandling.IGNORE); assertEquals(LDValue.buildArray().add("b").build(), - json(FEATURES, toMap(loader.load()).get(FEATURES).get("k")).get("variations")); - assertEquals(Collections.singletonList(b).get(0), loader.load().getFiles().get(0).getPath()); + json(FEATURES, toMap(loader.load(Duration.ZERO)).get(FEATURES).get("k")).get("variations")); + assertEquals(Collections.singletonList(b).get(0), loader.load(Duration.ZERO).getFiles().get(0).getPath()); + } + } + + private static final String YAML_DOC = "flagValues:\n flag: \"value\"\n"; + private static final String JSON_DOC = "{\"flagValues\":{\"flag\":\"value\"}}"; + private static final Duration RETRY = Duration.ofSeconds(1); + + /** + * A monotonic clock that the test moves. + */ + private static final class TestClock { + final AtomicLong nanos = new AtomicLong(1_000_000_000L); + + void advance(double retryDelays) { + nanos.addAndGet((long) (retryDelays * RETRY.toNanos())); + } + } + + private static OverrideFileLoader loader(TestClock clock, Path... paths) { + return new OverrideFileLoader(Arrays.asList(paths), FileData.DuplicateKeysHandling.FAIL, clock.nanos::get); + } + + // Loads and applies the result, as the reloader does with a result it applies. + private static LoadResult loadAndAccept(OverrideFileLoader loader) throws Exception { + LoadResult result = loader.load(RETRY); + loader.accepted(result); + return result; + } + + private static void assertNoContentFailure(OverrideFileLoader loader, Path file) throws Exception { + try { + loader.load(RETRY); + fail("expected exception for " + file); + } catch (FileDataException e) { + assertEquals("file " + file + ": the file has no content, which is not a JSON document", e.getMessage()); + assertNull(e.getCause()); + } + } + + private static void assertDeferred(OverrideFileLoader loader, Path... files) throws Exception { + try { + loader.load(RETRY); + fail("expected the empty files " + Arrays.asList(files) + " to be deferred"); + } catch (EmptiedFileException e) { + assertEquals(Arrays.asList(files), e.getPaths()); + } + } + + // Truncates the file again with a later modification time than the previous write. File system + // timestamps can be coarse, so it waits until the time differs. + private static void truncateAgain(Path file) throws Exception { + long before = Files.getLastModifiedTime(file).toMillis(); + long deadline = System.currentTimeMillis() + 5000; + do { + Thread.sleep(20); + InPlaceWrites.rewrite(file, "x", () -> { }); + InPlaceWrites.truncate(file); + } while (Files.getLastModifiedTime(file).toMillis() == before && System.currentTimeMillis() < deadline); + } + + @Test + public void formatIsChosenByTheFileNameExtension() throws Exception { + try (TempDir dir = TempDir.create()) { + for (String name : new String[] { "a.yaml", "a.yml", "a.YAML", "a.Yml" }) { + assertEquals(name, 1, loader(write(dir, name, YAML_DOC)).load(Duration.ZERO).getFlagCount()); + } + // JSON is valid YAML, so a YAML file can hold a JSON document. + assertEquals(1, loader(write(dir, "json.yaml", JSON_DOC)).load(Duration.ZERO).getFlagCount()); + // Any other file is JSON, whatever its content. + for (String name : new String[] { "b.json", "b.txt", "b", "b.yaml.bak" }) { + Path file = write(dir, name, YAML_DOC); + try { + loader(file).load(Duration.ZERO); + fail("expected exception for " + name); + } catch (FileDataException e) { + assertThat(e.getMessage(), containsString(file + ": cannot parse JSON")); + } + } + } + } + + @Test + public void emptyOrWhiteSpaceJsonFileFailsToParse() throws Exception { + try (TempDir dir = TempDir.create()) { + // A file with no extension or another extension is JSON, so it gets the same rule. + for (String name : new String[] { "empty.json", "empty", "empty.txt" }) { + for (String contents : new String[] { "", " \n\t\r\n" }) { + Path file = write(dir, name, contents); + assertNoContentFailure(loader(file), file); + } + } + } + } + + @Test + public void emptyOrWhiteSpaceYamlFileHasNoEntries() throws Exception { + try (TempDir dir = TempDir.create()) { + for (String name : new String[] { "empty.yaml", "empty.yml" }) { + for (String contents : new String[] { "", " \n\n" }) { + LoadResult result = loader(write(dir, name, contents)).load(RETRY); + assertEquals(0, result.getFlagCount()); + assertTrue(result.getFiles().get(0).isPresent()); + assertFalse(result.getFiles().get(0).hasContent()); + } + } + } + } + + @Test + public void emptiedYamlFileIsDeferredForOneRetryDelayAndThenHasNoEntries() throws Exception { + try (TempDir dir = TempDir.create()) { + Path file = write(dir, "overrides.yaml", YAML_DOC); + TestClock clock = new TestClock(); + OverrideFileLoader loader = loader(clock, file); + assertTrue(loadAndAccept(loader).getFiles().get(0).hasContent()); + + InPlaceWrites.truncate(file); + assertDeferred(loader, file); + clock.advance(0.85); + assertDeferred(loader, file); + // The file has stayed empty without being written for nine tenths of a retry delay, which is + // when the retry armed by the first deferral can fire if its timer is slightly early. + clock.advance(0.05); + assertEquals(0, loader.load(RETRY).getFlagCount()); + // It is not deferred again until it changes. + assertEquals(0, loader.load(RETRY).getFlagCount()); + } + } + + @Test + public void emptiedJsonFileIsDeferredForOneRetryDelayAndThenFailsToParse() throws Exception { + try (TempDir dir = TempDir.create()) { + Path file = write(dir, "overrides.json", JSON_DOC); + TestClock clock = new TestClock(); + OverrideFileLoader loader = loader(clock, file); + loadAndAccept(loader); + + InPlaceWrites.truncate(file); + assertDeferred(loader, file); + clock.advance(1); + assertNoContentFailure(loader, file); + assertNoContentFailure(loader, file); + } + } + + @Test + public void contentThatIsOnlyWhiteSpaceOrAByteOrderMarkCountsAsEmpty() throws Exception { + try (TempDir dir = TempDir.create()) { + Path file = write(dir, "overrides.yaml", YAML_DOC); + OverrideFileLoader loader = loader(new TestClock(), file); + for (String contents : new String[] { " \n\t\r\n", "\uFEFF", "\uFEFF\n" }) { + assertEquals(1, loadAndAccept(loader).getFlagCount()); + InPlaceWrites.rewrite(file, contents, () -> { }); + assertDeferred(loader, file); + InPlaceWrites.rewrite(file, YAML_DOC, () -> { }); + } + } + } + + @Test + public void contentThatWasReadButNotAppliedDoesNotDeferTheFile() throws Exception { + // Only the data in effect counts. A load whose result was not applied, for example because + // another file failed to parse, does not make the file's later emptiness a rewrite. + try (TempDir dir = TempDir.create()) { + Path file = write(dir, "overrides.yaml", YAML_DOC); + OverrideFileLoader loader = loader(new TestClock(), file); + loader.load(RETRY); + + InPlaceWrites.truncate(file); + assertEquals(0, loader.load(RETRY).getFlagCount()); + } + } + + @Test + public void fileThatIsEmptyFromTheStartIsNeverDeferred() throws Exception { + try (TempDir dir = TempDir.create()) { + Path placeholder = write(dir, "placeholder.yaml", ""); + Path data = write(dir, "data.json", JSON_DOC); + OverrideFileLoader loader = loader(new TestClock(), placeholder, data); + for (int i = 0; i < 3; i++) { + assertEquals(1, loadAndAccept(loader).getFlagCount()); + } + truncateAgain(placeholder); + assertEquals(1, loadAndAccept(loader).getFlagCount()); + } + } + + @Test + public void fileWhoseEmptyStateIsInEffectIsNotDeferredAgain() throws Exception { + try (TempDir dir = TempDir.create()) { + Path file = write(dir, "overrides.yaml", YAML_DOC); + TestClock clock = new TestClock(); + OverrideFileLoader loader = loader(clock, file); + loadAndAccept(loader); + + InPlaceWrites.truncate(file); + assertDeferred(loader, file); + clock.advance(1); + assertEquals(0, loadAndAccept(loader).getFlagCount()); + + // A new truncation of the file whose empty state is in effect is parsed right away. + truncateAgain(file); + assertEquals(0, loader.load(RETRY).getFlagCount()); + } + } + + @Test + public void fileThatIsDeletedAndCreatedEmptyIsParsed() throws Exception { + try (TempDir dir = TempDir.create()) { + Path file = write(dir, "overrides.yaml", YAML_DOC); + OverrideFileLoader loader = loader(new TestClock(), file); + loadAndAccept(loader); + + Files.delete(file); + assertFalse(loadAndAccept(loader).getFiles().get(0).isPresent()); + Files.write(file, new byte[0]); + LoadResult result = loader.load(RETRY); + assertTrue(result.getFiles().get(0).isPresent()); + assertEquals(0, result.getFlagCount()); + } + } + + @Test + public void newTruncationStartsTheGraceAgain() throws Exception { + // A write and a new truncation that no load saw in between: the file is a new rewrite, not one + // that has stayed empty. + try (TempDir dir = TempDir.create()) { + Path file = write(dir, "overrides.yaml", YAML_DOC); + TestClock clock = new TestClock(); + OverrideFileLoader loader = loader(clock, file); + loadAndAccept(loader); + + InPlaceWrites.truncate(file); + assertDeferred(loader, file); + clock.advance(0.8); + truncateAgain(file); + assertDeferred(loader, file); + clock.advance(0.8); + assertDeferred(loader, file); + clock.advance(0.1); + assertEquals(0, loader.load(RETRY).getFlagCount()); + } + } + + @Test + public void fileThatKeepsBeingEmptiedIsParsedAfterTheCap() throws Exception { + try (TempDir dir = TempDir.create()) { + Path file = write(dir, "overrides.yaml", YAML_DOC); + TestClock clock = new TestClock(); + OverrideFileLoader loader = loader(clock, file); + loadAndAccept(loader); + + InPlaceWrites.truncate(file); + assertDeferred(loader, file); + // Every load finds a new truncation, so the grace keeps starting again, up to the cap of five + // retry delays from when the file was first found empty. + for (int i = 0; i < 5; i++) { + clock.advance(0.9); + truncateAgain(file); + assertDeferred(loader, file); + } + clock.advance(0.5); + truncateAgain(file); + assertEquals(0, loader.load(RETRY).getFlagCount()); + } + } + + @Test + public void contentReadEndsTheEpisode() throws Exception { + // The cap counts from when the file was first found empty in the current episode. + try (TempDir dir = TempDir.create()) { + Path file = write(dir, "overrides.yaml", YAML_DOC); + TestClock clock = new TestClock(); + OverrideFileLoader loader = loader(clock, file); + loadAndAccept(loader); + + InPlaceWrites.truncate(file); + assertDeferred(loader, file); + clock.advance(4.5); + InPlaceWrites.rewrite(file, YAML_DOC, () -> { }); + assertEquals(1, loader.load(RETRY).getFlagCount()); + + truncateAgain(file); + clock.advance(0.1); + assertDeferred(loader, file); + clock.advance(0.5); + assertDeferred(loader, file); + } + } + + @Test + public void missingFileEndsTheEpisode() throws Exception { + try (TempDir dir = TempDir.create()) { + Path file = write(dir, "overrides.yaml", YAML_DOC); + TestClock clock = new TestClock(); + OverrideFileLoader loader = loader(clock, file); + loadAndAccept(loader); + + InPlaceWrites.truncate(file); + assertDeferred(loader, file); + clock.advance(4.5); + Files.delete(file); + assertFalse(loader.load(RETRY).getFiles().get(0).isPresent()); + + Files.write(file, new byte[0]); + clock.advance(0.6); + assertDeferred(loader, file); + } + } + + @Test + public void withoutRetriesAnEmptiedFileIsParsedRightAway() throws Exception { + try (TempDir dir = TempDir.create()) { + Path file = write(dir, "overrides.yaml", YAML_DOC); + OverrideFileLoader loader = loader(new TestClock(), file); + loader.accepted(loader.load(Duration.ZERO)); + + InPlaceWrites.truncate(file); + assertEquals(0, loader.load(Duration.ZERO).getFlagCount()); + } + } + + @Test + public void filesEmptiedTogetherAreDeferredFromTheSameLoad() throws Exception { + // A load that defers one file still reads the others, so each emptied file starts its delay in + // that load and all of them are parsed after one retry delay, not one retry delay per file. + try (TempDir dir = TempDir.create()) { + Path first = write(dir, "first.yaml", YAML_DOC); + Path second = write(dir, "second.yaml", "flagValues:\n other: \"value\"\n"); + TestClock clock = new TestClock(); + OverrideFileLoader loader = loader(clock, first, second); + assertEquals(2, loadAndAccept(loader).getFlagCount()); + + InPlaceWrites.truncate(first); + InPlaceWrites.truncate(second); + assertDeferred(loader, first, second); + clock.advance(0.9); + assertEquals(0, loader.load(RETRY).getFlagCount()); + } + } + + @Test + public void failureOfAnotherFileFailsALoadThatDefersAFile() throws Exception { + try (TempDir dir = TempDir.create()) { + Path first = write(dir, "first.yaml", YAML_DOC); + Path second = write(dir, "second.json", JSON_DOC.replace("flag", "other")); + OverrideFileLoader loader = loader(new TestClock(), first, second); + loadAndAccept(loader); + + InPlaceWrites.truncate(first); + InPlaceWrites.rewrite(second, "{\"flagValues\"", () -> { }); + try { + loader.load(RETRY); + fail("expected exception"); + } catch (FileDataException e) { + assertThat(e.getMessage(), containsString(second + ": cannot parse JSON")); + } } } } From aa1b3f53a25a1004363fbc6577c553c368f936c4 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Tue, 3 Nov 2026 01:14:55 +0000 Subject: [PATCH 25/30] fix: Bound the hold of emptied override files by the run of holding loads The cap on how long an emptied file is held back counted from when that file was first found empty, and a read with content reset it, so files rewritten in turn were held back without end. Loads now stop holding files back once they have held some back for five retry delays in a row; any load that holds none back ends the run. A file is held back until it has stayed empty, without being written, for exactly the retry delay, and the retry that a hold arms comes when the first delay or the run ends, plus a tenth of the retry delay. This replaces the nine-tenths slack, which let another load apply a shorter gap. YAML content that holds no document, such as a partial read with only a comment or a document marker, counts as empty, while {} is applied right away. The state of a file, its modification time and size, is read through any symbolic link. --- .../server/integrations/FileDataPoller.java | 9 +- .../server/integrations/FileDataReloader.java | 103 +++--- .../integrations/FileDataSourceParsing.java | 16 +- .../integrations/FileOverrideSourceImpl.java | 4 +- .../server/integrations/FileOverrides.java | 14 +- .../integrations/OverrideFileLoader.java | 311 +++++++++------- .../FileDataReloaderEmptiedFileTest.java | 54 +++ .../integrations/FileDataReloaderTest.java | 39 ++- .../integrations/OverrideFileLoaderTest.java | 331 ++++++++++++++---- 9 files changed, 621 insertions(+), 260 deletions(-) diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FileDataPoller.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FileDataPoller.java index 954e22e6..65100cb8 100644 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FileDataPoller.java +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FileDataPoller.java @@ -104,7 +104,14 @@ static List observeAll(List paths) { return states; } - private static FileState observe(Path path) { + /** + * Observes one file. The attributes are read through any symbolic link, so they describe the file + * that the link points to. + * + * @param path the file + * @return its state, or {@link FileState#ABSENT} if its attributes cannot be read + */ + static FileState observe(Path path) { try { BasicFileAttributes attributes = Files.readAttributes(path, BasicFileAttributes.class); return new FileState(true, attributes.lastModifiedTime().toMillis(), attributes.size()); diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FileDataReloader.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FileDataReloader.java index d5d51897..a62dfbbb 100644 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FileDataReloader.java +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FileDataReloader.java @@ -28,8 +28,8 @@ * parsed or when the files cannot be merged. *

* A file whose content is in effect and is found empty may be in the middle of an in-place rewrite. - * The loader can defer such a file with {@link EmptiedFileException}: the reloader keeps the current - * data, reports no failure, and reads the files again after the retry delay. + * The loader can hold such a file back with {@link EmptiedFileException}: the reloader keeps the + * current data, reports no failure, and reads the files again when the exception says. *

* The file data source does not use this class. It keeps its own reload behavior. */ @@ -54,12 +54,12 @@ interface Loader { /** * Reads every configured file and merges the entries. * - * @param retryDelay the reloader's retry delay, or zero if it does not retry. A loader may defer - * an empty file for a time based on it, because a retry reads the files again. With zero, - * nothing would read them again, so it must not defer. + * @param retryDelay the reloader's retry delay, or zero if it does not retry. A loader may hold + * an empty file back for a time based on it, because a retry reads the files again. With zero, + * nothing would read them again, so it must not hold any back. * @return the merged result * @throws FileDataException if a file cannot be read or parsed, or the files cannot be merged - * @throws EmptiedFileException if a file was found empty and is deferred + * @throws EmptiedFileException if a file was found empty and is held back */ LoadResult load(Duration retryDelay) throws FileDataException, EmptiedFileException; @@ -73,24 +73,33 @@ interface Loader { } /** - * Thrown by a {@link Loader} that defers a file it found empty. The file may be in the middle of an + * Thrown by a {@link Loader} that holds back files it found empty. A file may be in the middle of an * in-place rewrite: a writer that truncates the file and then writes the new content. */ @SuppressWarnings("serial") static final class EmptiedFileException extends Exception { private final List paths; + private final long retryAfterNanos; - EmptiedFileException(List paths) { + EmptiedFileException(List paths, long retryAfterNanos) { super("files " + paths + " are empty"); this.paths = Collections.unmodifiableList(new ArrayList<>(paths)); + this.retryAfterNanos = retryAfterNanos; } /** - * The deferred files, in configuration order. + * The files held back, in configuration order. */ List getPaths() { return paths; } + + /** + * How long to wait before loading again, in nanoseconds. + */ + long getRetryAfterNanos() { + return retryAfterNanos; + } } /** @@ -126,6 +135,8 @@ interface Handler { private final LDLogger logger; private final long debounceDelayMillis; private final long retryDelayMillis; + // What reload() returns when no retry is needed. + private static final long NO_RETRY = -1; private final boolean skipUnchanged; // Serializes the load work between reloadNow and the worker thread. @@ -156,9 +167,9 @@ interface Handler { * @param logger receives log output about reloads and failures * @param debounceDelay how long to wait after a trigger for further triggers to settle before * reloading. Zero or negative reloads on every trigger without waiting. - * @param retryDelay how long to wait after a failed reload before retrying without a trigger, - * and after a load that defers an empty file before reading the files again. Zero or negative - * disables the automatic retry, and the loader then parses an empty file as it is. + * @param retryDelay how long to wait after a failed reload before retrying without a trigger. A + * load that holds back an empty file says itself when to load again. Zero or negative disables + * the automatic retry, and the loader then parses an empty file as it is. * @param skipUnchanged if true, a successful load whose raw file contents are identical to the * last applied contents does not call the apply callback */ @@ -183,8 +194,9 @@ interface Handler { * initial load. A failure here arms the same automatic retry as a failed triggered reload. */ void reloadNow() { - if (!reload() && retryDelayMillis > 0) { - armRetry(); + long retryAfterNanos = reload(); + if (retryAfterNanos != NO_RETRY) { + armRetry(retryAfterNanos); } } @@ -280,7 +292,7 @@ private ScheduledThreadPoolExecutor ensureWorker() { return worker; } - private void armRetry() { + private void armRetry(long delayNanos) { synchronized (timerLock) { if (closed.get()) { return; @@ -289,7 +301,7 @@ private void armRetry() { if (retryFuture != null && !retryFuture.isDone()) { return; } - retryFuture = ensureWorker().schedule(() -> runReload(true), retryDelayMillis, TimeUnit.MILLISECONDS); + retryFuture = ensureWorker().schedule(() -> runReload(true), delayNanos, TimeUnit.NANOSECONDS); } } @@ -310,8 +322,9 @@ private void runReload(boolean isRetry) { retryFuture = null; } } - if (!reload() && retryDelayMillis > 0) { - armRetry(); + long retryAfterNanos = reload(); + if (retryAfterNanos != NO_RETRY) { + armRetry(retryAfterNanos); } } catch (RuntimeException e) { // The executor would otherwise swallow the exception and the worker would go quiet. @@ -320,15 +333,16 @@ private void runReload(boolean isRetry) { } } - // Performs one full load of all configured files. It reports whether the load succeeded, which - // decides whether a retry gets armed. A skipped no-op application counts as success, and a load - // that defers an empty file does not. The whole set is re-read on every reload because entries are - // combined across files in order, so a change to one file can alter which file wins for a key. - private boolean reload() { + // Performs one full load of all configured files. It returns when to load again: NO_RETRY after a + // success, including a skipped no-op application, the retry delay after a failure, or the time that + // a load that holds back an empty file asks for. The whole set is re-read on every reload because + // entries are combined across files in order, so a change to one file can alter which file wins + // for a key. + private long reload() { synchronized (reloadLock) { // A trigger already queued when close was called can still reach here. if (closed.get()) { - return true; + return NO_RETRY; } readAgainPending = false; @@ -336,14 +350,14 @@ private boolean reload() { try { result = loader.load(retryDelayMillis > 0 ? Duration.ofMillis(retryDelayMillis) : Duration.ZERO); } catch (EmptiedFileException e) { - return readAgainLater(e.getPaths()); + return readAgainLater(e); } catch (FileDataException e) { return fail(e); } // Close may have happened while the files were being read. Deliver nothing in that case. if (closed.get()) { - return true; + return NO_RETRY; } // A success right after a failure must apply even when the content is unchanged since the @@ -353,7 +367,7 @@ private boolean reload() { byte[] hash = result.getContentHash(); if (skipUnchanged && !recovering && hash != null && Arrays.equals(hash, lastGoodHash)) { loader.accepted(result); - return true; + return NO_RETRY; } // Nothing is remembered until the consumer has accepted the result. A result that the // consumer rejects must not become the baseline that skip-unchanged compares against, and @@ -366,42 +380,43 @@ private boolean reload() { lastErrorMessage = null; lastGoodHash = hash; loader.accepted(result); - return true; + return NO_RETRY; } } - // Called inside the reload lock. The loader deferred a file that it found empty: a file whose content - // is in effect may be in the middle of an in-place rewrite, a writer that truncates the file and then - // writes the new content. Whatever the file's format, the load keeps the current data and arms the - // retry without reporting a failure, so the files are read again after the retry delay. The change - // signal for the write starts a load sooner. The loader decides how long a file can be deferred, and - // defers nothing when retries are off, because nothing would read the file again. A closed reloader - // arms no retry. - private boolean readAgainLater(List paths) { - for (Path path : paths) { + // Called inside the reload lock. The loader held back files that it found empty: a file whose + // content is in effect may be in the middle of an in-place rewrite, a writer that truncates the file + // and then writes the new content. Whatever the files' format, the load keeps the current data and + // arms the retry without reporting a failure, for the time that the loader asks for. The change + // signal for the write starts a load sooner. The loader decides how long a file can be held back, and + // holds nothing back when retries are off, because nothing would read the file again. A closed + // reloader arms no retry. + private long readAgainLater(EmptiedFileException e) { + for (Path path : e.getPaths()) { logger.debug("File {} is empty, possibly while it is being rewritten; reading it again before applying it", path); } readAgainPending = true; - return false; + return e.getRetryAfterNanos(); } - private boolean fail(FileDataException e) { - // Close may have happened while the files were being read. Deliver nothing and report success - // so that no retry is armed. + // Reports a failure and returns when to retry. + private long fail(FileDataException e) { + // Close may have happened while the files were being read. Deliver nothing and arm no retry. if (closed.get()) { - return true; + return NO_RETRY; } + long retryAfterNanos = retryDelayMillis > 0 ? TimeUnit.MILLISECONDS.toNanos(retryDelayMillis) : NO_RETRY; // With automatic retries, a persistent failure would repeat the same log entry and the same // callback on every attempt. Repeats of an identical failure are logged at debug level and do // not call onError again. A consumer therefore sees one report per distinct failure. String description = describe(e); if (description.equals(lastErrorMessage)) { logger.debug("Unable to load flags: {}", description); - return false; + return retryAfterNanos; } lastErrorMessage = description; logger.error("Unable to load flags: {}", description); handler.onError(e); - return false; + return retryAfterNanos; } } diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FileDataSourceParsing.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FileDataSourceParsing.java index 1558fab4..abf8e764 100644 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FileDataSourceParsing.java +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FileDataSourceParsing.java @@ -2,7 +2,6 @@ import com.google.gson.Gson; import com.google.gson.JsonElement; -import com.google.gson.JsonObject; import com.google.gson.JsonSyntaxException; import com.launchdarkly.sdk.LDValue; import com.launchdarkly.sdk.ObjectBuilder; @@ -66,6 +65,13 @@ public String getDescription() { * parse the flags or segments at this level; that will be done by {@link FlagFactory}. */ static final class FlagFileRep { + /** + * What a parser returns for content that holds no document, such as YAML with only a comment or a + * document marker. It has no entries. The override source compares a result with it by identity, + * so it must not be modified. + */ + static final FlagFileRep NO_DOCUMENT = new FlagFileRep(); + Map flags; Map flagValues; Map segments; @@ -202,8 +208,12 @@ public FlagFileRep parse(InputStream input) throws FileDataException, IOExceptio } catch (YAMLException e) { throw new FileDataException("unable to parse YAML", e); } - JsonElement jsonRoot = root == null ? new JsonObject() : gson.toJsonTree(root); - return jsonFileParser.parseJson(jsonRoot); + if (root == null) { + // Empty content, or content with only comments or document markers, holds no document. It has + // no entries. + return FlagFileRep.NO_DOCUMENT; + } + return jsonFileParser.parseJson(gson.toJsonTree(root)); } } diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FileOverrideSourceImpl.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FileOverrideSourceImpl.java index 971acf8f..ca9ea071 100644 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FileOverrideSourceImpl.java +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FileOverrideSourceImpl.java @@ -24,8 +24,8 @@ * A configured file that does not exist contributes no overrides. A file that exists but cannot * be read or parsed fails that reload: the sink is not called, so the last good overrides stay in * effect, and the reload is retried. A file whose content is in effect and is found empty may be in - * the middle of an in-place rewrite, so it is applied only once it has stayed empty for about the - * retry delay, or has been found empty for five retry delays in all. + * the middle of an in-place rewrite, so it is held back until it has stayed empty, without being + * written, for the retry delay, or until loads have held files back for five retry delays in a row. */ final class FileOverrideSourceImpl implements OverrideSource { /** diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FileOverrides.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FileOverrides.java index 3f8d060b..e418276b 100644 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FileOverrides.java +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FileOverrides.java @@ -100,12 +100,14 @@ private FileOverrides() {} * A file can be rewritten in place. To remove a file's overrides, delete the file or write a * document with no entries, such as {}. A JSON file with no content is not a valid * document: it fails to load, and the overrides in effect stay. A file whose content is in effect - * and that is found empty may be in the middle of a rewrite, so the source keeps the overrides in - * effect until the file has stayed empty for about a second: then an emptied YAML file removes its - * overrides, and an emptied JSON file fails to load. A file that keeps being emptied takes effect - * within about five seconds. YAML that is cut off between two entries can still parse, so for a - * YAML file, replacing its entire contents in one operation, such as renaming a fully written - * temporary file over it, avoids briefly applying only a part of it. + * and that is found empty, or with YAML content that holds no document, such as only a comment, may + * be in the middle of a rewrite. The source then keeps the overrides in effect until the file has + * stayed so, without being written, for about a second, or at most about five seconds while files + * keep being rewritten. Changes to the other files wait too, because a reload applies all the files + * or none. After that, an empty YAML file removes its overrides, while an empty JSON file fails to + * load and the overrides in effect stay. YAML that is cut off between two entries can still parse, + * so for a YAML file, replacing its entire contents in one operation, such as renaming a fully + * written temporary file over it, avoids briefly applying only a part of it. *

* Whenever the set of overrides in effect changes, including at startup, the source logs the * overrides in effect and what each configured file supplied, at Info level. diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/OverrideFileLoader.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/OverrideFileLoader.java index dcb8aa8b..9a9a119d 100644 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/OverrideFileLoader.java +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/OverrideFileLoader.java @@ -17,7 +17,6 @@ import java.nio.file.Files; import java.nio.file.NoSuchFileException; import java.nio.file.Path; -import java.nio.file.attribute.FileTime; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.time.Duration; @@ -27,7 +26,6 @@ import java.util.HashMap; import java.util.List; import java.util.Map; -import java.util.Objects; import java.util.function.LongSupplier; import static com.launchdarkly.sdk.server.DataModel.FEATURES; @@ -46,24 +44,26 @@ * file with no content is not a JSON document, so it fails to parse. A YAML file with no content is a * document with no entries. *

- * A file that is rewritten in place is empty for a moment, and a load can find it so. The loader + * A file that is rewritten in place is empty for a moment, and a load can find it so. A file counts + * as empty when it holds only white space, or when its parser reads its content as no document, as the + * YAML parser does for a part of a file that holds only a comment or a document marker. The loader * remembers, for each file, whether it contributed content to the data in effect, and since when it - * has been found empty. A file whose content is in effect and is found empty is deferred, not parsed: - * {@link #load(Duration)} still reads the other files, so that files emptied together are deferred - * from the same load, and then throws {@link EmptiedFileException}. The data in effect stays, and the - * reloader reads the files again after its retry delay. The file is parsed once it has stayed empty, - * without being written, for about one retry delay, or once it has been found empty for five retry - * delays in all, so that a file that keeps being rewritten cannot hold its change back for longer. A file that - * never contributed content, such as a placeholder, and a file whose empty state is in effect are - * parsed right away. The reloader calls {@link #load(Duration)} and {@link #accepted(LoadResult)} one - * call at a time, so this state needs no synchronization. + * has been found empty without being written. A file whose content is in effect and that is found + * empty is held back, not parsed: {@link #load(Duration)} still reads the other files, so that files + * emptied together are held back from the same load, and then throws {@link EmptiedFileException}. + * The data in effect stays, and the reloader reads the files again when the exception says. A file is + * held back until it has stayed empty, without being written, for one retry delay. Loads stop holding + * files back once they have held some back for five retry delays in a row, so that files that keep + * being rewritten cannot hold their changes back for longer. A file that never contributed content, + * such as a placeholder, and a file whose empty state is in effect are parsed right away. The reloader + * calls {@link #load(Duration)} and {@link #accepted(LoadResult)} one call at a time, so this state + * needs no synchronization. */ final class OverrideFileLoader implements FileDataReloader.Loader { /** - * How many retry delays, counted from when a file was first found empty, a file that keeps being - * found empty can be deferred for. + * How many retry delays in a row loads can hold files back for. */ - static final int MAX_DEFERRAL_RETRY_DELAYS = 5; + static final int MAX_HOLD_RETRY_DELAYS = 5; /** * Describes one configured file after a load. @@ -170,21 +170,17 @@ byte[] getContentHash() { } /** - * The time during which a file whose content is in effect has been found empty. + * A file whose content is in effect and that loads have found empty: since when it has been so + * without being written, from the monotonic clock, and its state then. The state is the modification + * time and size of the file, read through any symbolic link, and is only compared for equality. */ - private static final class EmptyEpisode { - // When the file was first found empty, and when it was last found empty after a write; from the - // monotonic clock. - final long firstSeenNanos; + private static final class EmptyFile { long sinceNanos; - // The modification time of the file when it was last found empty after a write, or null if it - // could not be read. It is only compared for equality, never with the clock. - FileTime modified; - - EmptyEpisode(long nowNanos, FileTime modified) { - this.firstSeenNanos = nowNanos; - this.sinceNanos = nowNanos; - this.modified = modified; + FileDataPoller.FileState state; + + EmptyFile(long sinceNanos, FileDataPoller.FileState state) { + this.sinceNanos = sinceNanos; + this.state = state; } } @@ -194,8 +190,12 @@ private static final class EmptyEpisode { // Whether each configured file contributed content to the data in effect. Updated only by // accepted(LoadResult). A file that is not listed did not. private final Map appliedHadContent = new HashMap<>(); - // The files whose content is in effect and that are being found empty. - private final Map emptyEpisodes = new HashMap<>(); + // The files whose content is in effect and that loads have found empty since. An entry is removed + // when a load reads the file with content. + private final Map emptyFiles = new HashMap<>(); + // When the current run of loads that hold files back began, from the monotonic clock; null when the + // last load did not hold any back. + private Long holdingSinceNanos = null; /** * Creates a loader. @@ -221,98 +221,134 @@ private static final class EmptyEpisode { * Reads every configured file in order and merges the entries. * * @param retryDelay the reloader's retry delay, or zero if it does not retry. A file whose content is - * in effect and that is found empty, or white space only, is deferred while it has stayed empty - * without being written for less than nine tenths of this, and has been found empty for less - * than {@link #MAX_DEFERRAL_RETRY_DELAYS} times this. With zero, every file is parsed as it is. + * in effect and that is found empty is held back until it has stayed empty, without being written, + * for this long, and loads stop holding files back once they have held some back for + * {@link #MAX_HOLD_RETRY_DELAYS} times this in a row. With zero, every file is parsed as it is. * @return the merged result with a summary of each file * @throws FileDataException if a file that exists cannot be read or parsed, or the files cannot be - * merged, also when another file is deferred - * @throws EmptiedFileException if a file is deferred and every other file loads + * merged, also when another file is held back + * @throws EmptiedFileException if a file is held back and every other file loads */ @Override public LoadResult load(Duration retryDelay) throws FileDataException, EmptiedFileException { - MessageDigest digest = newDigest(); - Map> data = new HashMap<>(); - List files = new ArrayList<>(paths.size()); - List deferred = new ArrayList<>(); - for (Path path : paths) { - byte[] raw; - try { - raw = Files.readAllBytes(path); - } catch (NoSuchFileException e) { - // Absence is a valid state, not a failure. - emptyEpisodes.remove(path); - files.add(new FileSummary(path, false, false, 0, 0)); - continue; - } catch (IOException e) { - throw new FileDataException("file " + path + ": unable to read file", e); + long now = nanoClock.getAsLong(); + boolean held = false; + try { + MessageDigest digest = newDigest(); + Map> data = new HashMap<>(); + List files = new ArrayList<>(paths.size()); + List heldBack = new ArrayList<>(); + for (Path path : paths) { + byte[] raw; + try { + raw = Files.readAllBytes(path); + } catch (NoSuchFileException e) { + // Absence is a valid state, not a failure. + files.add(new FileSummary(path, false, false, 0, 0)); + continue; + } catch (IOException e) { + throw new FileDataException("file " + path + ": unable to read file", e); + } + // Content that the parser reads as no document, such as YAML with only a comment, has no + // entries, and a read can see that much of a file that is being written. Content with only + // white space is invalid JSON, so it is parsed only when the file is not held back. + FlagFileRep document = null; + boolean hasContent = hasContent(raw); + if (hasContent) { + document = parse(path, raw); + hasContent = document != FlagFileRep.NO_DOCUMENT; + } + if (hasContent) { + emptyFiles.remove(path); + } else if (shouldHoldBack(path, retryDelay, now)) { + // The other files are still read, so that each emptied file starts its delay in this load, + // and a failure of another file still fails the load. + heldBack.add(path); + continue; + } + // One read feeds both the digest and the parse, so the skip-unchanged digest can never disagree + // with the content that was applied. + if (digest != null) { + digest.update(raw); + digest.update((byte) 0); + } + if (document == null) { + document = parse(path, raw); + } + int[] added = addEntries(data, path, document); + files.add(new FileSummary(path, true, hasContent, added[0], added[1])); } - boolean hasContent = hasContent(raw); - if (hasContent) { - emptyEpisodes.remove(path); - } else if (deferEmptyFile(path, retryDelay)) { - // The other files are still read, so that each emptied file starts its delay in this load, and - // a failure of another file still fails the load. - deferred.add(path); - continue; + + if (!heldBack.isEmpty()) { + held = true; + throw new EmptiedFileException(heldBack, holdBack(heldBack, retryDelay, now)); } - // One read feeds both the digest and the parse, so the skip-unchanged digest can never disagree - // with the content that was applied. - if (digest != null) { - digest.update(raw); - digest.update((byte) 0); + + ImmutableList.Builder>> all = ImmutableList.builder(); + for (Map.Entry> e : data.entrySet()) { + all.add(new AbstractMap.SimpleEntry<>(e.getKey(), new KeyedItems<>(ImmutableList.copyOf(e.getValue().entrySet())))); + } + Map flags = data.get(FEATURES); + Map segments = data.get(SEGMENTS); + return new LoadResult(all.build(), files, flags == null ? 0 : flags.size(), + segments == null ? 0 : segments.size(), digest == null ? null : digest.digest()); + } finally { + if (!held) { + // A run of loads that hold files back ends with any load that does not, whether it succeeds + // or fails. + holdingSinceNanos = null; } - int flagsAdded = 0; - int segmentsAdded = 0; - try { - FlagFileParser parser = FlagFileParser.selectForPath(path); - FlagFileRep fileContents = parser.parse(new ByteArrayInputStream(raw)); - if (fileContents.flags != null) { - for (Map.Entry e : fileContents.flags.entrySet()) { - if (add(data, FEATURES, e.getKey(), FlagFactory.flagFromJson(e.getValue()))) { - flagsAdded++; - } + } + } + + private static FlagFileRep parse(Path path, byte[] raw) throws FileDataException { + try { + return FlagFileParser.selectForPath(path).parse(new ByteArrayInputStream(raw)); + } catch (FileDataException e) { + throw new FileDataException("file " + path + ": " + e.getMessage(), e.getCause()); + } catch (IOException e) { + throw new FileDataException("file " + path + ": cannot read document", e); + } catch (RuntimeException e) { + throw new FileDataException("file " + path + ": cannot parse flag or segment data", e); + } + } + + // Adds the entries of one file and returns how many flags and segments the merge kept from it. + private int[] addEntries(Map> data, Path path, FlagFileRep document) + throws FileDataException { + int flagsAdded = 0; + int segmentsAdded = 0; + try { + if (document.flags != null) { + for (Map.Entry e : document.flags.entrySet()) { + if (add(data, FEATURES, e.getKey(), FlagFactory.flagFromJson(e.getValue()))) { + flagsAdded++; } } - if (fileContents.flagValues != null) { - for (Map.Entry e : fileContents.flagValues.entrySet()) { - if (add(data, FEATURES, e.getKey(), FlagFactory.flagWithValue(e.getKey(), e.getValue(), 0))) { - flagsAdded++; - } + } + if (document.flagValues != null) { + for (Map.Entry e : document.flagValues.entrySet()) { + if (add(data, FEATURES, e.getKey(), FlagFactory.flagWithValue(e.getKey(), e.getValue(), 0))) { + flagsAdded++; } } - if (fileContents.segments != null) { - for (Map.Entry e : fileContents.segments.entrySet()) { - if (add(data, SEGMENTS, e.getKey(), FlagFactory.segmentFromJson(e.getValue()))) { - segmentsAdded++; - } + } + if (document.segments != null) { + for (Map.Entry e : document.segments.entrySet()) { + if (add(data, SEGMENTS, e.getKey(), FlagFactory.segmentFromJson(e.getValue()))) { + segmentsAdded++; } } - } catch (FileDataException e) { - throw new FileDataException("file " + path + ": " + e.getMessage(), e.getCause()); - } catch (IOException e) { - throw new FileDataException("file " + path + ": cannot read document", e); - } catch (RuntimeException e) { - // A document can be valid JSON or YAML and still hold a flag or segment that the data model - // cannot accept. For the override source that is a parse failure of the file, so the last good - // overrides stay in effect. - throw new FileDataException("file " + path + ": cannot parse flag or segment data", e); } - files.add(new FileSummary(path, true, hasContent, flagsAdded, segmentsAdded)); - } - - if (!deferred.isEmpty()) { - throw new EmptiedFileException(deferred); + } catch (FileDataException e) { + throw new FileDataException("file " + path + ": " + e.getMessage(), e.getCause()); + } catch (RuntimeException e) { + // A document can be valid JSON or YAML and still hold a flag or segment that the data model + // cannot accept. For the override source that is a parse failure of the file, so the last good + // overrides stay in effect. + throw new FileDataException("file " + path + ": cannot parse flag or segment data", e); } - - ImmutableList.Builder>> all = ImmutableList.builder(); - for (Map.Entry> e : data.entrySet()) { - all.add(new AbstractMap.SimpleEntry<>(e.getKey(), new KeyedItems<>(ImmutableList.copyOf(e.getValue().entrySet())))); - } - Map flags = data.get(FEATURES); - Map segments = data.get(SEGMENTS); - return new LoadResult(all.build(), files, flags == null ? 0 : flags.size(), - segments == null ? 0 : segments.size(), digest == null ? null : digest.digest()); + return new int[] { flagsAdded, segmentsAdded }; } /** @@ -329,42 +365,49 @@ public void accepted(LoadResult result) { } } - // Decides whether a file that was read empty is deferred. A file whose content is not in effect is - // parsed right away: a placeholder that never had content, or a file whose empty state was applied. - // Otherwise the file may be in the middle of an in-place rewrite. The episode starts when the file - // is first found empty, and a new modification time means a new rewrite, which starts the grace - // again. The file is deferred within nine tenths of a retry delay of the latest start of the grace, - // and within the cap of the first time it was found empty. The retry that a deferral arms comes one - // retry delay later, and a timer can fire slightly early, so the slack lets that retry parse a file - // that has stayed empty instead of deferring it for a second retry delay. The episode is kept when - // the file is parsed, so later loads keep parsing it until it changes. A zero retry delay, which - // means that the reloader does not retry, gives no grace, so nothing is deferred: nothing would read - // the file again. - private boolean deferEmptyFile(Path path, Duration retryDelay) { + // Decides whether a file that a load found empty is held back. A file whose content is in effect + // and that is empty now may be in the middle of an in-place rewrite: a writer that truncates the file + // and then writes the new content. Such a file is held back until it has stayed empty, without being + // written, for the retry delay. A change of its state means that it was written since a load last + // found it empty, for example rewritten and truncated again between two loads, so the delay starts + // over. A file that keeps being written while every load finds it empty, or files that are rewritten + // in turn so that every load finds one of them empty, would be held back without end, so loads stop + // holding files back once they have held some back for MAX_HOLD_RETRY_DELAYS retry delays in a row. + // A file whose content is not in effect, such as a placeholder that has always been empty, or a file + // whose empty state was applied, is parsed right away. A zero retry delay, which means that the + // reloader does not retry, holds nothing back: nothing would read the file again. Elapsed times are + // differences of monotonic clock readings, which do not overflow. + private boolean shouldHoldBack(Path path, Duration retryDelay, long now) { if (!Boolean.TRUE.equals(appliedHadContent.get(path))) { return false; } - long now = nanoClock.getAsLong(); - FileTime modified = modifiedTime(path); - EmptyEpisode episode = emptyEpisodes.get(path); - if (episode == null) { - episode = new EmptyEpisode(now, modified); - emptyEpisodes.put(path, episode); - } else if (!Objects.equals(modified, episode.modified)) { - episode.sinceNanos = now; - episode.modified = modified; + FileDataPoller.FileState state = FileDataPoller.observe(path); + EmptyFile empty = emptyFiles.get(path); + if (empty == null) { + emptyFiles.put(path, empty = new EmptyFile(now, state)); + } else if (!empty.state.equals(state)) { + empty.sinceNanos = now; + empty.state = state; } long delay = retryDelay.toNanos(); - return now - episode.sinceNanos < delay - delay / 10 - && now - episode.firstSeenNanos < MAX_DEFERRAL_RETRY_DELAYS * delay; + long holdingFor = holdingSinceNanos == null ? 0 : now - holdingSinceNanos; + return now - empty.sinceNanos < delay && holdingFor < MAX_HOLD_RETRY_DELAYS * delay; } - private static FileTime modifiedTime(Path path) { - try { - return Files.getLastModifiedTime(path); - } catch (IOException e) { - return null; + // Starts or continues the run of holding loads, and returns how long the reloader waits before it + // loads again: until the first of the held files has stayed empty for the retry delay, or until the + // run reaches its limit, whichever comes first, plus a tenth of the retry delay, so that a timer that + // fires a little early still finds the delay over. A change signal for a write starts a load sooner. + private long holdBack(List heldBack, Duration retryDelay, long now) { + if (holdingSinceNanos == null) { + holdingSinceNanos = now; + } + long delay = retryDelay.toNanos(); + long remaining = MAX_HOLD_RETRY_DELAYS * delay - (now - holdingSinceNanos); + for (Path path : heldBack) { + remaining = Math.min(remaining, delay - (now - emptyFiles.get(path).sinceNanos)); } + return Math.max(remaining, 0) + delay / 10; } // A file with only white space, or a byte order mark, has no content. diff --git a/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/integrations/FileDataReloaderEmptiedFileTest.java b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/integrations/FileDataReloaderEmptiedFileTest.java index 01a66afd..6f0b1d8c 100644 --- a/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/integrations/FileDataReloaderEmptiedFileTest.java +++ b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/integrations/FileDataReloaderEmptiedFileTest.java @@ -24,6 +24,8 @@ import java.util.List; import java.util.Map; import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicLong; +import java.util.function.LongSupplier; import static com.launchdarkly.sdk.server.DataModel.FEATURES; import static org.hamcrest.MatcherAssert.assertThat; @@ -98,6 +100,10 @@ private static final class CountingLoader implements FileDataReloader.Loader { loader = new OverrideFileLoader(Arrays.asList(paths), FileData.DuplicateKeysHandling.FAIL); } + CountingLoader(LongSupplier clock, Path... paths) { + loader = new OverrideFileLoader(Arrays.asList(paths), FileData.DuplicateKeysHandling.FAIL, clock); + } + @Override public LoadResult load(Duration retryDelay) throws FileDataException, EmptiedFileException { try { @@ -157,6 +163,54 @@ private static String readAgainLog(Path file) { return "DEBUG:File " + file + " is empty, possibly while it is being rewritten; reading it again before applying it"; } + // For the tests that move a clock themselves: a retry delay so long that no retry fires during the + // test, so every load is one the test starts with reloadNow. + private static final Duration HOUR = Duration.ofHours(1); + + @Test + public void holdEndsExactlyAfterTheRetryDelay() throws Exception { + try (TempDir dir = TempDir.create()) { + Path file = write(dir, "overrides.yaml", yaml("a")); + AtomicLong clock = new AtomicLong(); + CountingLoader loader = new CountingLoader(clock::get, file); + Recorder recorder = new Recorder(); + FileDataReloader r = reloader(loader, recorder, HOUR); + r.reloadNow(); + + InPlaceWrites.truncate(file); + r.reloadNow(); + clock.addAndGet(HOUR.toNanos() * 95 / 100); + r.reloadNow(); + assertEquals(Collections.singletonList("a"), recorder.applied); + clock.addAndGet(HOUR.toNanos() * 5 / 100); + r.reloadNow(); + assertEquals(Arrays.asList("a", "none"), recorder.applied); + assertTrue(recorder.errors.isEmpty()); + } + } + + @Test + public void changesToOtherFilesWaitWhileAFileIsHeld() throws Exception { + // A reload applies all the files or none. + try (TempDir dir = TempDir.create()) { + Path emptied = write(dir, "emptied.yaml", "flagValues:\n other: \"x\"\n"); + Path changed = write(dir, "changed.yaml", yaml("b1")); + AtomicLong clock = new AtomicLong(); + CountingLoader loader = new CountingLoader(clock::get, emptied, changed); + Recorder recorder = new Recorder(); + FileDataReloader r = reloader(loader, recorder, HOUR); + r.reloadNow(); + + InPlaceWrites.truncate(emptied); + InPlaceWrites.rewrite(changed, yaml("b2"), () -> { }); + r.reloadNow(); + assertEquals(Collections.singletonList("b1"), recorder.applied); + clock.addAndGet(HOUR.toNanos()); + r.reloadNow(); + assertEquals(Arrays.asList("b1", "b2"), recorder.applied); + } + } + @Test public void emptiedYamlFileIsReadAgainAndTheRewrittenContentIsApplied() throws Exception { try (TempDir dir = TempDir.create()) { diff --git a/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/integrations/FileDataReloaderTest.java b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/integrations/FileDataReloaderTest.java index 63280805..6b0d9834 100644 --- a/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/integrations/FileDataReloaderTest.java +++ b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/integrations/FileDataReloaderTest.java @@ -108,8 +108,12 @@ ScriptedLoader thenThrow(RuntimeException e) { } ScriptedLoader thenEmptied(Path path) { + return thenEmptied(path, SHORT); + } + + ScriptedLoader thenEmptied(Path path, Duration retryAfter) { steps.add(() -> { - throw new EmptiedFileException(Collections.singletonList(path)); + throw new EmptiedFileException(Collections.singletonList(path), retryAfter.toNanos()); }); return this; } @@ -585,6 +589,39 @@ public void emptiedFileIsReadAgainAfterTheRetryDelayWithoutReportingAFailure() t } } + @Test + public void holdIsRetriedAfterTheTimeTheLoaderAsksFor() throws Exception { + // The loader asks for a retry sooner than the retry delay, which would not come in this test. + LoadResult rewritten = resultWithHash(2); + ScriptedLoader loader = new ScriptedLoader().then(resultWithHash(1)).thenEmptied(EMPTIED, Duration.ofMillis(30)) + .then(rewritten); + RecordingHandler handler = new RecordingHandler(); + FileDataReloader r = reloader(loader, handler, Duration.ZERO, Duration.ofHours(1), true); + r.reloadNow(); + + r.trigger(); + awaitSize(handler.applied, 2); + assertEquals(rewritten, handler.applied.get(1)); + assertEquals(3, loader.calls.get()); + } + + @Test + public void failureAfterAHoldIsRetriedAfterTheRetryDelay() throws Exception { + // A hold asks for a retry soon. The failure that follows arms the normal retry delay, which does + // not come in this test. + ScriptedLoader loader = new ScriptedLoader().then(resultWithHash(1)).thenEmptied(EMPTIED, Duration.ofMillis(30)) + .thenFail("broken").then(resultWithHash(2)); + RecordingHandler handler = new RecordingHandler(); + FileDataReloader r = reloader(loader, handler, Duration.ZERO, Duration.ofHours(1), true); + r.reloadNow(); + + r.trigger(); + awaitSize(handler.errors, 1); + Thread.sleep(300); + assertEquals(3, loader.calls.get()); + assertEquals(1, handler.applied.size()); + } + @Test public void loaderIsGivenTheRetryDelayOrZeroWithoutRetries() { ScriptedLoader withRetries = new ScriptedLoader().then(resultWithHash(1)); diff --git a/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/integrations/OverrideFileLoaderTest.java b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/integrations/OverrideFileLoaderTest.java index 03b285dc..e96bdfb2 100644 --- a/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/integrations/OverrideFileLoaderTest.java +++ b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/integrations/OverrideFileLoaderTest.java @@ -15,6 +15,7 @@ import java.nio.file.Files; import java.nio.file.Path; +import java.nio.file.Paths; import java.time.Duration; import java.util.Arrays; import java.util.Collections; @@ -30,6 +31,7 @@ import static org.hamcrest.Matchers.equalTo; import static org.hamcrest.Matchers.instanceOf; import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.not; import static org.junit.Assert.assertArrayEquals; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; @@ -297,10 +299,11 @@ public void loaderReadsFilesInConfiguredOrder() throws Exception { private static final Duration RETRY = Duration.ofSeconds(1); /** - * A monotonic clock that the test moves. + * A monotonic clock that the test moves. It starts near the largest value, so that elapsed times + * cross the overflow of the clock's readings. */ private static final class TestClock { - final AtomicLong nanos = new AtomicLong(1_000_000_000L); + final AtomicLong nanos = new AtomicLong(Long.MAX_VALUE - RETRY.toNanos() / 2); void advance(double retryDelays) { nanos.addAndGet((long) (retryDelays * RETRY.toNanos())); @@ -328,25 +331,35 @@ private static void assertNoContentFailure(OverrideFileLoader loader, Path file) } } - private static void assertDeferred(OverrideFileLoader loader, Path... files) throws Exception { + // Asserts that a load holds back the given files and returns how long it asks the reloader to + // wait, in retry delays. + private static double assertHeld(OverrideFileLoader loader, Path... files) throws Exception { try { loader.load(RETRY); - fail("expected the empty files " + Arrays.asList(files) + " to be deferred"); + fail("expected the empty files " + Arrays.asList(files) + " to be held back"); + return 0; } catch (EmptiedFileException e) { assertEquals(Arrays.asList(files), e.getPaths()); + return (double) e.getRetryAfterNanos() / RETRY.toNanos(); } } - // Truncates the file again with a later modification time than the previous write. File system - // timestamps can be coarse, so it waits until the time differs. - private static void truncateAgain(Path file) throws Exception { + // Writes the file again, in place, with a later modification time than the previous write. File + // system timestamps can be coarse, so it waits until the time differs. It writes other content first, + // so that the file is modified even when the new content is what it already holds. + private static void writeAgain(Path file, String contents) throws Exception { long before = Files.getLastModifiedTime(file).toMillis(); long deadline = System.currentTimeMillis() + 5000; do { Thread.sleep(20); InPlaceWrites.rewrite(file, "x", () -> { }); - InPlaceWrites.truncate(file); + InPlaceWrites.rewrite(file, contents, () -> { }); } while (Files.getLastModifiedTime(file).toMillis() == before && System.currentTimeMillis() < deadline); + assertThat(Files.getLastModifiedTime(file).toMillis(), not(equalTo(before))); + } + + private static void truncateAgain(Path file) throws Exception { + writeAgain(file, ""); } @Test @@ -384,21 +397,32 @@ public void emptyOrWhiteSpaceJsonFileFailsToParse() throws Exception { } @Test - public void emptyOrWhiteSpaceYamlFileHasNoEntries() throws Exception { + public void yamlFileWithNoDocumentHasNoEntries() throws Exception { try (TempDir dir = TempDir.create()) { for (String name : new String[] { "empty.yaml", "empty.yml" }) { - for (String contents : new String[] { "", " \n\n" }) { + for (String contents : new String[] { "", " \n\n", "# only a comment\n", "---\n" }) { LoadResult result = loader(write(dir, name, contents)).load(RETRY); assertEquals(0, result.getFlagCount()); assertTrue(result.getFiles().get(0).isPresent()); - assertFalse(result.getFiles().get(0).hasContent()); + assertFalse(contents, result.getFiles().get(0).hasContent()); } } } } @Test - public void emptiedYamlFileIsDeferredForOneRetryDelayAndThenHasNoEntries() throws Exception { + public void documentWithNoEntriesHasContent() throws Exception { + try (TempDir dir = TempDir.create()) { + for (String name : new String[] { "empty.yaml", "empty.json" }) { + LoadResult result = loader(write(dir, name, "{}")).load(RETRY); + assertEquals(0, result.getFlagCount()); + assertTrue(name, result.getFiles().get(0).hasContent()); + } + } + } + + @Test + public void emptiedYamlFileIsHeldForExactlyTheRetryDelayAndThenHasNoEntries() throws Exception { try (TempDir dir = TempDir.create()) { Path file = write(dir, "overrides.yaml", YAML_DOC); TestClock clock = new TestClock(); @@ -406,20 +430,19 @@ public void emptiedYamlFileIsDeferredForOneRetryDelayAndThenHasNoEntries() throw assertTrue(loadAndAccept(loader).getFiles().get(0).hasContent()); InPlaceWrites.truncate(file); - assertDeferred(loader, file); - clock.advance(0.85); - assertDeferred(loader, file); - // The file has stayed empty without being written for nine tenths of a retry delay, which is - // when the retry armed by the first deferral can fire if its timer is slightly early. + assertHeld(loader, file); + clock.advance(0.95); + assertHeld(loader, file); + // The file has stayed empty without being written for the retry delay. clock.advance(0.05); assertEquals(0, loader.load(RETRY).getFlagCount()); - // It is not deferred again until it changes. + // It is not held back again until it changes. assertEquals(0, loader.load(RETRY).getFlagCount()); } } @Test - public void emptiedJsonFileIsDeferredForOneRetryDelayAndThenFailsToParse() throws Exception { + public void emptiedJsonFileIsHeldForTheRetryDelayAndThenFailsToParse() throws Exception { try (TempDir dir = TempDir.create()) { Path file = write(dir, "overrides.json", JSON_DOC); TestClock clock = new TestClock(); @@ -427,29 +450,133 @@ public void emptiedJsonFileIsDeferredForOneRetryDelayAndThenFailsToParse() throw loadAndAccept(loader); InPlaceWrites.truncate(file); - assertDeferred(loader, file); + assertHeld(loader, file); clock.advance(1); assertNoContentFailure(loader, file); assertNoContentFailure(loader, file); } } + @Test + public void holdAsksForARetryWhenTheFirstDelayEnds() throws Exception { + try (TempDir dir = TempDir.create()) { + Path first = write(dir, "first.yaml", YAML_DOC); + Path second = write(dir, "second.yaml", "flagValues:\n other: \"value\"\n"); + TestClock clock = new TestClock(); + OverrideFileLoader loader = loader(clock, first, second); + loadAndAccept(loader); + + // The retry comes when the delay ends, plus a tenth of the retry delay for a timer that fires a + // little early. + InPlaceWrites.truncate(first); + assertEquals(1.1, assertHeld(loader, first), 1e-9); + clock.advance(0.4); + assertEquals(0.7, assertHeld(loader, first), 1e-9); + // A second file found empty later does not delay the retry for the first. + InPlaceWrites.truncate(second); + clock.advance(0.2); + assertEquals(0.5, assertHeld(loader, first, second), 1e-9); + } + } + + @Test + public void holdAsksForARetryWhenTheRunOfHoldsEnds() throws Exception { + try (TempDir dir = TempDir.create()) { + Path file = write(dir, "overrides.yaml", YAML_DOC); + TestClock clock = new TestClock(); + OverrideFileLoader loader = loader(clock, file); + loadAndAccept(loader); + + InPlaceWrites.truncate(file); + assertHeld(loader, file); + for (int i = 0; i < 4; i++) { + clock.advance(0.9); + writeAgain(file, " "); + assertHeld(loader, file); + } + // 4.6 retry delays into the run, the file is written again. Its own delay would end at 5.6, the + // run's at 5.0. + clock.advance(1.0); + writeAgain(file, " "); + assertEquals(0.5, assertHeld(loader, file), 1e-9); + } + } + @Test public void contentThatIsOnlyWhiteSpaceOrAByteOrderMarkCountsAsEmpty() throws Exception { + // A read can see only the byte order mark that an editor writes first. For JSON that is not a + // parse failure to report but an empty file to hold back. + for (String name : new String[] { "overrides.yaml", "overrides.json" }) { + try (TempDir dir = TempDir.create()) { + String doc = name.endsWith(".json") ? JSON_DOC : YAML_DOC; + Path file = write(dir, name, doc); + OverrideFileLoader loader = loader(new TestClock(), file); + for (String contents : new String[] { " \n\t\r\n", "\uFEFF", "\uFEFF\n" }) { + assertEquals(name, 1, loadAndAccept(loader).getFlagCount()); + InPlaceWrites.rewrite(file, contents, () -> { }); + assertHeld(loader, file); + InPlaceWrites.rewrite(file, doc, () -> { }); + } + } + } + } + + @Test + public void fileEmptiedAgainWithTheSameStateAfterContentStartsANewDelay() throws Exception { + // File system timestamps are coarse, so a file can be emptied again with the modification time + // and size that it had when it was last found empty. A load that read content in between shows + // that this is a new emptying. + try (TempDir dir = TempDir.create()) { + Path file = write(dir, "overrides.yaml", YAML_DOC); + TestClock clock = new TestClock(); + OverrideFileLoader loader = loader(clock, file); + loadAndAccept(loader); + + InPlaceWrites.truncate(file); + java.nio.file.attribute.FileTime emptiedAt = Files.getLastModifiedTime(file); + assertHeld(loader, file); + clock.advance(2); + InPlaceWrites.rewrite(file, YAML_DOC, () -> { }); + assertEquals(1, loader.load(RETRY).getFlagCount()); + + InPlaceWrites.truncate(file); + Files.setLastModifiedTime(file, emptiedAt); + assertHeld(loader, file); + } + } + + @Test + public void yamlWithOnlyACommentOrADocumentMarkerIsHeld() throws Exception { + // A read can see only the first line of a YAML file that is being written. try (TempDir dir = TempDir.create()) { Path file = write(dir, "overrides.yaml", YAML_DOC); OverrideFileLoader loader = loader(new TestClock(), file); - for (String contents : new String[] { " \n\t\r\n", "\uFEFF", "\uFEFF\n" }) { + for (String contents : new String[] { "# overrides for the incident\n", "---\n" }) { assertEquals(1, loadAndAccept(loader).getFlagCount()); InPlaceWrites.rewrite(file, contents, () -> { }); - assertDeferred(loader, file); + assertHeld(loader, file); InPlaceWrites.rewrite(file, YAML_DOC, () -> { }); } } } @Test - public void contentThatWasReadButNotAppliedDoesNotDeferTheFile() throws Exception { + public void documentWithNoEntriesIsAppliedRightAway() throws Exception { + // {} is how to remove a file's overrides without deleting it. + try (TempDir dir = TempDir.create()) { + for (String name : new String[] { "overrides.yaml", "overrides.json" }) { + Path file = write(dir, name, name.endsWith(".json") ? JSON_DOC : YAML_DOC); + OverrideFileLoader loader = loader(new TestClock(), file); + loadAndAccept(loader); + + InPlaceWrites.rewrite(file, "{}", () -> { }); + assertEquals(name, 0, loader.load(RETRY).getFlagCount()); + } + } + } + + @Test + public void contentThatWasReadButNotAppliedDoesNotHoldTheFile() throws Exception { // Only the data in effect counts. A load whose result was not applied, for example because // another file failed to parse, does not make the file's later emptiness a rewrite. try (TempDir dir = TempDir.create()) { @@ -463,21 +590,23 @@ public void contentThatWasReadButNotAppliedDoesNotDeferTheFile() throws Exceptio } @Test - public void fileThatIsEmptyFromTheStartIsNeverDeferred() throws Exception { - try (TempDir dir = TempDir.create()) { - Path placeholder = write(dir, "placeholder.yaml", ""); - Path data = write(dir, "data.json", JSON_DOC); - OverrideFileLoader loader = loader(new TestClock(), placeholder, data); - for (int i = 0; i < 3; i++) { + public void fileThatHasNoContentFromTheStartIsNeverHeld() throws Exception { + for (String placeholderContents : new String[] { "", "# placeholder\n" }) { + try (TempDir dir = TempDir.create()) { + Path placeholder = write(dir, "placeholder.yaml", placeholderContents); + Path data = write(dir, "data.json", JSON_DOC); + OverrideFileLoader loader = loader(new TestClock(), placeholder, data); + for (int i = 0; i < 3; i++) { + assertEquals(1, loadAndAccept(loader).getFlagCount()); + } + truncateAgain(placeholder); assertEquals(1, loadAndAccept(loader).getFlagCount()); } - truncateAgain(placeholder); - assertEquals(1, loadAndAccept(loader).getFlagCount()); } } @Test - public void fileWhoseEmptyStateIsInEffectIsNotDeferredAgain() throws Exception { + public void fileWhoseEmptyStateIsInEffectIsNotHeldAgain() throws Exception { try (TempDir dir = TempDir.create()) { Path file = write(dir, "overrides.yaml", YAML_DOC); TestClock clock = new TestClock(); @@ -485,12 +614,12 @@ public void fileWhoseEmptyStateIsInEffectIsNotDeferredAgain() throws Exception { loadAndAccept(loader); InPlaceWrites.truncate(file); - assertDeferred(loader, file); + assertHeld(loader, file); clock.advance(1); assertEquals(0, loadAndAccept(loader).getFlagCount()); // A new truncation of the file whose empty state is in effect is parsed right away. - truncateAgain(file); + writeAgain(file, " "); assertEquals(0, loader.load(RETRY).getFlagCount()); } } @@ -512,8 +641,8 @@ public void fileThatIsDeletedAndCreatedEmptyIsParsed() throws Exception { } @Test - public void newTruncationStartsTheGraceAgain() throws Exception { - // A write and a new truncation that no load saw in between: the file is a new rewrite, not one + public void newWriteStartsTheDelayAgain() throws Exception { + // A write and a new truncation that no load saw in between: the file is in a new rewrite, not one // that has stayed empty. try (TempDir dir = TempDir.create()) { Path file = write(dir, "overrides.yaml", YAML_DOC); @@ -522,19 +651,48 @@ public void newTruncationStartsTheGraceAgain() throws Exception { loadAndAccept(loader); InPlaceWrites.truncate(file); - assertDeferred(loader, file); + assertHeld(loader, file); clock.advance(0.8); - truncateAgain(file); - assertDeferred(loader, file); + writeAgain(file, YAML_DOC); + InPlaceWrites.truncate(file); + assertHeld(loader, file); + clock.advance(0.95); + assertHeld(loader, file); + clock.advance(0.05); + assertEquals(0, loader.load(RETRY).getFlagCount()); + } + } + + @Test + public void newWriteOfALinkedFileStartsTheDelayAgain() throws Exception { + // The state of a file that is configured through a symbolic link is that of the file it points + // to; the link itself does not change when the file is written. + try (TempDir dir = TempDir.create()) { + Path target = write(dir, "target.yaml", YAML_DOC); + Path link = dir.getPath().resolve("overrides.yaml"); + try { + Files.createSymbolicLink(link, target); + } catch (UnsupportedOperationException | java.io.IOException e) { + org.junit.Assume.assumeNoException("symbolic links are not available", e); + } + TestClock clock = new TestClock(); + OverrideFileLoader loader = loader(clock, link); + loadAndAccept(loader); + + InPlaceWrites.truncate(target); + assertHeld(loader, link); clock.advance(0.8); - assertDeferred(loader, file); - clock.advance(0.1); + truncateAgain(target); + assertHeld(loader, link); + clock.advance(0.95); + assertHeld(loader, link); + clock.advance(0.05); assertEquals(0, loader.load(RETRY).getFlagCount()); } } @Test - public void fileThatKeepsBeingEmptiedIsParsedAfterTheCap() throws Exception { + public void fileThatKeepsBeingWrittenIsParsedAfterTheRunOfHolds() throws Exception { try (TempDir dir = TempDir.create()) { Path file = write(dir, "overrides.yaml", YAML_DOC); TestClock clock = new TestClock(); @@ -542,13 +700,13 @@ public void fileThatKeepsBeingEmptiedIsParsedAfterTheCap() throws Exception { loadAndAccept(loader); InPlaceWrites.truncate(file); - assertDeferred(loader, file); - // Every load finds a new truncation, so the grace keeps starting again, up to the cap of five - // retry delays from when the file was first found empty. + assertHeld(loader, file); + // Every load finds a new write, so the delay keeps starting again, up to the limit of five + // retry delays for the run of loads that hold files back. for (int i = 0; i < 5; i++) { clock.advance(0.9); truncateAgain(file); - assertDeferred(loader, file); + assertHeld(loader, file); } clock.advance(0.5); truncateAgain(file); @@ -557,8 +715,35 @@ public void fileThatKeepsBeingEmptiedIsParsedAfterTheCap() throws Exception { } @Test - public void contentReadEndsTheEpisode() throws Exception { - // The cap counts from when the file was first found empty in the current episode. + public void filesRewrittenInTurnAreParsedAfterTheRunOfHolds() throws Exception { + // Each load finds one of the files empty and the other with content, so each file's own delay + // starts again whenever it is found empty. The run of holds still ends. + try (TempDir dir = TempDir.create()) { + Path a = write(dir, "a.yaml", YAML_DOC); + Path b = write(dir, "b.yaml", "flagValues:\n other: \"value\"\n"); + TestClock clock = new TestClock(); + OverrideFileLoader loader = loader(clock, a, b); + loadAndAccept(loader); + + InPlaceWrites.truncate(a); + assertHeld(loader, a); + for (int i = 0; i < 9; i++) { + clock.advance(0.5); + Path emptied = i % 2 == 0 ? b : a; + Path written = i % 2 == 0 ? a : b; + writeAgain(written, written == a ? YAML_DOC : "flagValues:\n other: \"value\"\n"); + truncateAgain(emptied); + assertHeld(loader, emptied); + } + clock.advance(0.5); + writeAgain(b, "flagValues:\n other: \"value\"\n"); + truncateAgain(a); + assertEquals(1, loader.load(RETRY).getFlagCount()); + } + } + + @Test + public void loadThatHoldsNothingBackEndsTheRun() throws Exception { try (TempDir dir = TempDir.create()) { Path file = write(dir, "overrides.yaml", YAML_DOC); TestClock clock = new TestClock(); @@ -566,36 +751,44 @@ public void contentReadEndsTheEpisode() throws Exception { loadAndAccept(loader); InPlaceWrites.truncate(file); - assertDeferred(loader, file); - clock.advance(4.5); - InPlaceWrites.rewrite(file, YAML_DOC, () -> { }); + assertHeld(loader, file); + for (int i = 0; i < 5; i++) { + clock.advance(0.9); + truncateAgain(file); + assertHeld(loader, file); + } + // A load that reads the file with content ends the run, so the next emptying starts a new one. + writeAgain(file, YAML_DOC); assertEquals(1, loader.load(RETRY).getFlagCount()); - truncateAgain(file); clock.advance(0.1); - assertDeferred(loader, file); + assertHeld(loader, file); clock.advance(0.5); - assertDeferred(loader, file); + assertHeld(loader, file); } } @Test - public void missingFileEndsTheEpisode() throws Exception { + public void failingLoadEndsTheRun() throws Exception { + // A JSON file that stays empty is parsed after the delay and fails, which ends the run. When it is + // later rewritten in place, the new truncation is held back again instead of reported. try (TempDir dir = TempDir.create()) { - Path file = write(dir, "overrides.yaml", YAML_DOC); + Path file = write(dir, "overrides.json", JSON_DOC); TestClock clock = new TestClock(); OverrideFileLoader loader = loader(clock, file); loadAndAccept(loader); InPlaceWrites.truncate(file); - assertDeferred(loader, file); - clock.advance(4.5); - Files.delete(file); - assertFalse(loader.load(RETRY).getFiles().get(0).isPresent()); + assertHeld(loader, file); + clock.advance(1); + assertNoContentFailure(loader, file); + clock.advance(10); + assertNoContentFailure(loader, file); - Files.write(file, new byte[0]); - clock.advance(0.6); - assertDeferred(loader, file); + writeAgain(file, " "); + assertHeld(loader, file); + InPlaceWrites.rewrite(file, JSON_DOC, () -> { }); + assertEquals(1, loader.load(RETRY).getFlagCount()); } } @@ -612,9 +805,9 @@ public void withoutRetriesAnEmptiedFileIsParsedRightAway() throws Exception { } @Test - public void filesEmptiedTogetherAreDeferredFromTheSameLoad() throws Exception { - // A load that defers one file still reads the others, so each emptied file starts its delay in - // that load and all of them are parsed after one retry delay, not one retry delay per file. + public void filesEmptiedTogetherAreHeldFromTheSameLoad() throws Exception { + // A load that holds back one file still reads the others, so each emptied file starts its delay + // in that load and all of them are parsed after one retry delay, not one retry delay per file. try (TempDir dir = TempDir.create()) { Path first = write(dir, "first.yaml", YAML_DOC); Path second = write(dir, "second.yaml", "flagValues:\n other: \"value\"\n"); @@ -624,14 +817,14 @@ public void filesEmptiedTogetherAreDeferredFromTheSameLoad() throws Exception { InPlaceWrites.truncate(first); InPlaceWrites.truncate(second); - assertDeferred(loader, first, second); - clock.advance(0.9); + assertHeld(loader, first, second); + clock.advance(1); assertEquals(0, loader.load(RETRY).getFlagCount()); } } @Test - public void failureOfAnotherFileFailsALoadThatDefersAFile() throws Exception { + public void failureOfAnotherFileFailsALoadThatHoldsAFile() throws Exception { try (TempDir dir = TempDir.create()) { Path first = write(dir, "first.yaml", YAML_DOC); Path second = write(dir, "second.json", JSON_DOC.replace("flag", "other")); From 82902ecb62591cf38548d322c433a1fbeb6993f4 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Tue, 3 Nov 2026 02:58:25 +0000 Subject: [PATCH 26/30] fix: Bound each held-back override file on its own and read the clock per file The bound on how long emptied files are held back covered the whole run of holding loads, so a failing other file restarted it, and one file reaching it cut short another file's ordinary rewrite. Each file now has its own bound of five retry delays from the first load that held it back. A read with content and a failed load keep it; applying data, or a load skipped as unchanged, starts every bound over, and a file that stayed empty for the whole delay gets a fresh one. The clock is read after each file's own read, and again for the hold's retry, so a slow read of another file no longer shortens a file's delay. The retry after a hold logs that a file was found empty. --- .../server/integrations/FileDataReloader.java | 16 +- .../integrations/FileOverrideSourceImpl.java | 3 +- .../server/integrations/FileOverrides.java | 6 +- .../integrations/OverrideFileLoader.java | 236 ++++++++++-------- .../FileDataReloaderEmptiedFileTest.java | 144 ++++++++++- .../integrations/FileDataReloaderTest.java | 6 +- .../integrations/OverrideFileLoaderTest.java | 189 ++++++++++++-- 7 files changed, 468 insertions(+), 132 deletions(-) diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FileDataReloader.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FileDataReloader.java index a62dfbbb..e844d1ca 100644 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FileDataReloader.java +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FileDataReloader.java @@ -152,6 +152,9 @@ interface Handler { private ScheduledThreadPoolExecutor worker = null; private ScheduledFuture debounceFuture = null; private ScheduledFuture retryFuture = null; + // The delay of the latest request to arm a retry, in nanoseconds, or -1 before the first. Visible for + // tests through getLastRequestedRetryNanos. + private volatile long lastRequestedRetryNanos = -1; private final AtomicBoolean closed = new AtomicBoolean(false); @@ -245,6 +248,16 @@ public void close() { } } + /** + * Returns the delay of the latest request to arm a retry, whether or not it armed one. Visible for + * tests. + * + * @return the delay in nanoseconds, or -1 if no retry was requested + */ + long getLastRequestedRetryNanos() { + return lastRequestedRetryNanos; + } + /** * Reports whether the worker thread has been started. Visible for tests. * @@ -293,6 +306,7 @@ private ScheduledThreadPoolExecutor ensureWorker() { } private void armRetry(long delayNanos) { + lastRequestedRetryNanos = delayNanos; synchronized (timerLock) { if (closed.get()) { return; @@ -309,7 +323,7 @@ private void armRetry(long delayNanos) { private void runReload(boolean isRetry) { try { if (isRetry) { - logger.debug(readAgainPending ? "Reading flag data again after finding an empty file" + logger.debug(readAgainPending ? "Reading flag data again after a file was found empty" : "Retrying flag data load after earlier failure"); } else { logger.info("Reloading flag data after detecting a change"); diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FileOverrideSourceImpl.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FileOverrideSourceImpl.java index ca9ea071..aeae0563 100644 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FileOverrideSourceImpl.java +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FileOverrideSourceImpl.java @@ -25,7 +25,8 @@ * be read or parsed fails that reload: the sink is not called, so the last good overrides stay in * effect, and the reload is retried. A file whose content is in effect and is found empty may be in * the middle of an in-place rewrite, so it is held back until it has stayed empty, without being - * written, for the retry delay, or until loads have held files back for five retry delays in a row. + * written, for the retry delay, and for at most five retry delays from the first load that held it + * back. */ final class FileOverrideSourceImpl implements OverrideSource { /** diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FileOverrides.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FileOverrides.java index e418276b..006b783d 100644 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FileOverrides.java +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FileOverrides.java @@ -102,9 +102,9 @@ private FileOverrides() {} * document: it fails to load, and the overrides in effect stay. A file whose content is in effect * and that is found empty, or with YAML content that holds no document, such as only a comment, may * be in the middle of a rewrite. The source then keeps the overrides in effect until the file has - * stayed so, without being written, for about a second, or at most about five seconds while files - * keep being rewritten. Changes to the other files wait too, because a reload applies all the files - * or none. After that, an empty YAML file removes its overrides, while an empty JSON file fails to + * stayed so, without being written, for about a second, or for at most about five seconds while the + * file keeps being rewritten. Changes to the other files wait too, because a reload applies all the + * files or none. After that, an empty YAML file removes its overrides, while an empty JSON file fails to * load and the overrides in effect stay. YAML that is cut off between two entries can still parse, * so for a YAML file, replacing its entire contents in one operation, such as renaming a fully * written temporary file over it, avoids briefly applying only a part of it. diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/OverrideFileLoader.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/OverrideFileLoader.java index 9a9a119d..86e2ab5f 100644 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/OverrideFileLoader.java +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/OverrideFileLoader.java @@ -52,19 +52,26 @@ * empty is held back, not parsed: {@link #load(Duration)} still reads the other files, so that files * emptied together are held back from the same load, and then throws {@link EmptiedFileException}. * The data in effect stays, and the reloader reads the files again when the exception says. A file is - * held back until it has stayed empty, without being written, for one retry delay. Loads stop holding - * files back once they have held some back for five retry delays in a row, so that files that keep - * being rewritten cannot hold their changes back for longer. A file that never contributed content, - * such as a placeholder, and a file whose empty state is in effect are parsed right away. The reloader - * calls {@link #load(Duration)} and {@link #accepted(LoadResult)} one call at a time, so this state - * needs no synchronization. + * held back until it has stayed empty, without being written, for one retry delay, and for at most + * five retry delays from the first load that held it back, so that a file that keeps being rewritten + * cannot hold the changes back for longer. The bound is per file and starts over when data is + * applied. A file that never contributed content, such as a placeholder, and a file whose empty state + * is in effect are parsed right away. The reloader calls {@link #load(Duration)} and + * {@link #accepted(LoadResult)} one call at a time, so this state needs no synchronization. */ final class OverrideFileLoader implements FileDataReloader.Loader { /** - * How many retry delays in a row loads can hold files back for. + * How many retry delays a file can be held back for, from the first load that held it back. */ static final int MAX_HOLD_RETRY_DELAYS = 5; + /** + * Reads a file. Tests substitute one that takes time. + */ + interface FileReader { + byte[] read(Path path) throws IOException; + } + /** * Describes one configured file after a load. */ @@ -170,18 +177,20 @@ byte[] getContentHash() { } /** - * A file whose content is in effect and that loads have found empty: since when it has been so - * without being written, from the monotonic clock, and its state then. The state is the modification - * time and size of the file, read through any symbolic link, and is only compared for equality. + * A file whose content is in effect and that loads have found empty since data was last applied. + * Times are from the monotonic clock. */ private static final class EmptyFile { - long sinceNanos; + // Since when the file has been empty without being written, or null after a load read it with + // content. + Long sinceNanos; + // The state of the file then: its modification time and size, read through any symbolic link. It + // is only compared for equality. FileDataPoller.FileState state; - - EmptyFile(long sinceNanos, FileDataPoller.FileState state) { - this.sinceNanos = sinceNanos; - this.state = state; - } + // When a load first held the file back, or null if none has since the file last stayed empty for + // the whole retry delay. A read with content and a failed load keep it, so that a file that keeps + // being rewritten stays bounded. + Long firstHeldNanos; } private final List paths; @@ -190,12 +199,9 @@ private static final class EmptyFile { // Whether each configured file contributed content to the data in effect. Updated only by // accepted(LoadResult). A file that is not listed did not. private final Map appliedHadContent = new HashMap<>(); - // The files whose content is in effect and that loads have found empty since. An entry is removed - // when a load reads the file with content. + private final FileReader reader; + // The files whose content is in effect and that loads have found empty since data was last applied. private final Map emptyFiles = new HashMap<>(); - // When the current run of loads that hold files back began, from the monotonic clock; null when the - // last load did not hold any back. - private Long holdingSinceNanos = null; /** * Creates a loader. @@ -212,9 +218,18 @@ private static final class EmptyFile { */ OverrideFileLoader(List paths, FileData.DuplicateKeysHandling duplicateKeysHandling, LongSupplier nanoClock) { + this(paths, duplicateKeysHandling, nanoClock, Files::readAllBytes); + } + + /** + * Creates a loader with a specific monotonic clock and file reader. Visible for tests. + */ + OverrideFileLoader(List paths, FileData.DuplicateKeysHandling duplicateKeysHandling, + LongSupplier nanoClock, FileReader reader) { this.paths = new ArrayList<>(paths); this.duplicateKeysHandling = duplicateKeysHandling; this.nanoClock = nanoClock; + this.reader = reader; } /** @@ -222,8 +237,8 @@ private static final class EmptyFile { * * @param retryDelay the reloader's retry delay, or zero if it does not retry. A file whose content is * in effect and that is found empty is held back until it has stayed empty, without being written, - * for this long, and loads stop holding files back once they have held some back for - * {@link #MAX_HOLD_RETRY_DELAYS} times this in a row. With zero, every file is parsed as it is. + * for this long, and for at most {@link #MAX_HOLD_RETRY_DELAYS} times this from the first load + * that held it back. With zero, every file is parsed as it is. * @return the merged result with a summary of each file * @throws FileDataException if a file that exists cannot be read or parsed, or the files cannot be * merged, also when another file is held back @@ -231,74 +246,67 @@ private static final class EmptyFile { */ @Override public LoadResult load(Duration retryDelay) throws FileDataException, EmptiedFileException { - long now = nanoClock.getAsLong(); - boolean held = false; - try { - MessageDigest digest = newDigest(); - Map> data = new HashMap<>(); - List files = new ArrayList<>(paths.size()); - List heldBack = new ArrayList<>(); - for (Path path : paths) { - byte[] raw; - try { - raw = Files.readAllBytes(path); - } catch (NoSuchFileException e) { - // Absence is a valid state, not a failure. - files.add(new FileSummary(path, false, false, 0, 0)); - continue; - } catch (IOException e) { - throw new FileDataException("file " + path + ": unable to read file", e); - } - // Content that the parser reads as no document, such as YAML with only a comment, has no - // entries, and a read can see that much of a file that is being written. Content with only - // white space is invalid JSON, so it is parsed only when the file is not held back. - FlagFileRep document = null; - boolean hasContent = hasContent(raw); - if (hasContent) { - document = parse(path, raw); - hasContent = document != FlagFileRep.NO_DOCUMENT; - } - if (hasContent) { - emptyFiles.remove(path); - } else if (shouldHoldBack(path, retryDelay, now)) { - // The other files are still read, so that each emptied file starts its delay in this load, - // and a failure of another file still fails the load. - heldBack.add(path); - continue; - } - // One read feeds both the digest and the parse, so the skip-unchanged digest can never disagree - // with the content that was applied. - if (digest != null) { - digest.update(raw); - digest.update((byte) 0); - } - if (document == null) { - document = parse(path, raw); - } - int[] added = addEntries(data, path, document); - files.add(new FileSummary(path, true, hasContent, added[0], added[1])); + MessageDigest digest = newDigest(); + Map> data = new HashMap<>(); + List files = new ArrayList<>(paths.size()); + List heldBack = new ArrayList<>(); + for (Path path : paths) { + byte[] raw; + try { + raw = reader.read(path); + } catch (NoSuchFileException e) { + // Absence is a valid state, not a failure. + files.add(new FileSummary(path, false, false, 0, 0)); + continue; + } catch (IOException e) { + throw new FileDataException("file " + path + ": unable to read file", e); } - - if (!heldBack.isEmpty()) { - held = true; - throw new EmptiedFileException(heldBack, holdBack(heldBack, retryDelay, now)); + // Content that the parser reads as no document, such as YAML with only a comment, has no + // entries, and a read can see that much of a file that is being written. Content with only + // white space is invalid JSON, so it is parsed only when the file is not held back. + FlagFileRep document = null; + boolean hasContent = hasContent(raw); + if (hasContent) { + document = parse(path, raw); + hasContent = document != FlagFileRep.NO_DOCUMENT; } - - ImmutableList.Builder>> all = ImmutableList.builder(); - for (Map.Entry> e : data.entrySet()) { - all.add(new AbstractMap.SimpleEntry<>(e.getKey(), new KeyedItems<>(ImmutableList.copyOf(e.getValue().entrySet())))); + if (hasContent) { + EmptyFile empty = emptyFiles.get(path); + if (empty != null) { + empty.sinceNanos = null; + } + } else if (shouldHoldBack(path, retryDelay, nanoClock.getAsLong())) { + // The clock is read after this file's own read, so a slow read of another file does not + // shorten its delay. The other files are still read, so that each emptied file starts its + // delay in this load, and a failure of another file still fails the load. + heldBack.add(path); + continue; } - Map flags = data.get(FEATURES); - Map segments = data.get(SEGMENTS); - return new LoadResult(all.build(), files, flags == null ? 0 : flags.size(), - segments == null ? 0 : segments.size(), digest == null ? null : digest.digest()); - } finally { - if (!held) { - // A run of loads that hold files back ends with any load that does not, whether it succeeds - // or fails. - holdingSinceNanos = null; + // One read feeds both the digest and the parse, so the skip-unchanged digest can never disagree + // with the content that was applied. + if (digest != null) { + digest.update(raw); + digest.update((byte) 0); } + if (document == null) { + document = parse(path, raw); + } + int[] added = addEntries(data, path, document); + files.add(new FileSummary(path, true, hasContent, added[0], added[1])); + } + + if (!heldBack.isEmpty()) { + throw new EmptiedFileException(heldBack, holdBack(heldBack, retryDelay, nanoClock.getAsLong())); + } + + ImmutableList.Builder>> all = ImmutableList.builder(); + for (Map.Entry> e : data.entrySet()) { + all.add(new AbstractMap.SimpleEntry<>(e.getKey(), new KeyedItems<>(ImmutableList.copyOf(e.getValue().entrySet())))); } + Map flags = data.get(FEATURES); + Map segments = data.get(SEGMENTS); + return new LoadResult(all.build(), files, flags == null ? 0 : flags.size(), + segments == null ? 0 : segments.size(), digest == null ? null : digest.digest()); } private static FlagFileRep parse(Path path, byte[] raw) throws FileDataException { @@ -363,6 +371,8 @@ public void accepted(LoadResult result) { for (FileSummary file : result.getFiles()) { appliedHadContent.put(file.getPath(), file.hasContent()); } + // The data in effect has changed or been confirmed, so every file's delay and bound start over. + emptyFiles.clear(); } // Decides whether a file that a load found empty is held back. A file whose content is in effect @@ -370,42 +380,52 @@ public void accepted(LoadResult result) { // and then writes the new content. Such a file is held back until it has stayed empty, without being // written, for the retry delay. A change of its state means that it was written since a load last // found it empty, for example rewritten and truncated again between two loads, so the delay starts - // over. A file that keeps being written while every load finds it empty, or files that are rewritten - // in turn so that every load finds one of them empty, would be held back without end, so loads stop - // holding files back once they have held some back for MAX_HOLD_RETRY_DELAYS retry delays in a row. - // A file whose content is not in effect, such as a placeholder that has always been empty, or a file - // whose empty state was applied, is parsed right away. A zero retry delay, which means that the - // reloader does not retry, holds nothing back: nothing would read the file again. Elapsed times are - // differences of monotonic clock readings, which do not overflow. + // over. A file that keeps being written while loads find it empty would be held back without end, so + // each file is held back for at most MAX_HOLD_RETRY_DELAYS retry delays from the first load that held + // it back. The bound is the file's own, so one file reaching it does not cut short another file's + // rewrite, and it is kept across reads with content and failed loads, so files rewritten in turn and + // a failing other file cannot restart it. It starts over when data is applied, or when the file has + // stayed empty for the whole delay. A file whose content is not in effect, such as a placeholder that + // has always been empty, or a file whose empty state was applied, is parsed right away. A zero retry + // delay, which means that the reloader does not retry, holds nothing back: nothing would read the + // file again. Elapsed times are differences of monotonic clock readings, which do not overflow. private boolean shouldHoldBack(Path path, Duration retryDelay, long now) { if (!Boolean.TRUE.equals(appliedHadContent.get(path))) { return false; } FileDataPoller.FileState state = FileDataPoller.observe(path); - EmptyFile empty = emptyFiles.get(path); - if (empty == null) { - emptyFiles.put(path, empty = new EmptyFile(now, state)); - } else if (!empty.state.equals(state)) { + EmptyFile empty = emptyFiles.computeIfAbsent(path, p -> new EmptyFile()); + if (empty.sinceNanos == null || !state.equals(empty.state)) { empty.sinceNanos = now; empty.state = state; } long delay = retryDelay.toNanos(); - long holdingFor = holdingSinceNanos == null ? 0 : now - holdingSinceNanos; - return now - empty.sinceNanos < delay && holdingFor < MAX_HOLD_RETRY_DELAYS * delay; + if (now - empty.sinceNanos >= delay) { + // The file has stayed empty for the whole delay, so it is applied as it is. + empty.firstHeldNanos = null; + return false; + } + if (empty.firstHeldNanos != null && now - empty.firstHeldNanos >= MAX_HOLD_RETRY_DELAYS * delay) { + return false; + } + if (empty.firstHeldNanos == null) { + empty.firstHeldNanos = now; + } + return true; } - // Starts or continues the run of holding loads, and returns how long the reloader waits before it - // loads again: until the first of the held files has stayed empty for the retry delay, or until the - // run reaches its limit, whichever comes first, plus a tenth of the retry delay, so that a timer that - // fires a little early still finds the delay over. A change signal for a write starts a load sooner. + // Returns how long the reloader waits before it loads again: until the first of the held files has + // stayed empty for the retry delay or reaches its bound, plus a tenth of the retry delay, so that a + // timer that fires a little early still finds the time over. A change signal for a write starts a + // load sooner. private long holdBack(List heldBack, Duration retryDelay, long now) { - if (holdingSinceNanos == null) { - holdingSinceNanos = now; - } long delay = retryDelay.toNanos(); - long remaining = MAX_HOLD_RETRY_DELAYS * delay - (now - holdingSinceNanos); + long remaining = Long.MAX_VALUE; for (Path path : heldBack) { - remaining = Math.min(remaining, delay - (now - emptyFiles.get(path).sinceNanos)); + EmptyFile empty = emptyFiles.get(path); + long delayLeft = delay - (now - empty.sinceNanos); + long boundLeft = MAX_HOLD_RETRY_DELAYS * delay - (now - empty.firstHeldNanos); + remaining = Math.min(remaining, Math.min(delayLeft, boundLeft)); } return Math.max(remaining, 0) + delay / 10; } diff --git a/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/integrations/FileDataReloaderEmptiedFileTest.java b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/integrations/FileDataReloaderEmptiedFileTest.java index 6f0b1d8c..8f21e088 100644 --- a/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/integrations/FileDataReloaderEmptiedFileTest.java +++ b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/integrations/FileDataReloaderEmptiedFileTest.java @@ -46,7 +46,7 @@ public class FileDataReloaderEmptiedFileTest { // file again, also on a loaded machine. private static final Duration LONG_RETRY = Duration.ofMillis(1500); private static final long WAIT_MILLIS = 10000; - private static final String REREAD_LOG = "DEBUG:Reading flag data again after finding an empty file"; + private static final String REREAD_LOG = "DEBUG:Reading flag data again after a file was found empty"; private final LogCapture logCapture = Logs.capture(); private final LDLogger logger = LDLogger.withAdapter(logCapture, ""); @@ -167,6 +167,120 @@ private static String readAgainLog(Path file) { // test, so every load is one the test starts with reloadNow. private static final Duration HOUR = Duration.ofHours(1); + private static final long TENTH = HOUR.toNanos() / 10; + + // Writes the file again, in place, with a later modification time than the previous write. File + // system timestamps can be coarse, so it waits until the time differs. + private static void writeAgain(Path file, String contents) throws Exception { + long before = Files.getLastModifiedTime(file).toMillis(); + long deadline = System.currentTimeMillis() + 5000; + do { + Thread.sleep(20); + InPlaceWrites.rewrite(file, "x", () -> { }); + InPlaceWrites.rewrite(file, contents, () -> { }); + } while (Files.getLastModifiedTime(file).toMillis() == before && System.currentTimeMillis() < deadline); + } + + @Test + public void holdAsksForTheRetryWhenTheDelayIsOver() throws Exception { + try (TempDir dir = TempDir.create()) { + Path file = write(dir, "overrides.yaml", yaml("a")); + AtomicLong clock = new AtomicLong(); + FileDataReloader r = reloader(new CountingLoader(clock::get, file), new Recorder(), HOUR); + r.reloadNow(); + + InPlaceWrites.truncate(file); + r.reloadNow(); + assertEquals(HOUR.toNanos() + TENTH, r.getLastRequestedRetryNanos()); + + // A later load asks for the rest of the delay, not a whole one. + clock.addAndGet(Duration.ofMinutes(30).toNanos()); + r.reloadNow(); + assertEquals(Duration.ofMinutes(30).toNanos() + TENTH, r.getLastRequestedRetryNanos()); + } + } + + @Test + public void holdAsksForTheRetryWhenTheBoundIsOver() throws Exception { + try (TempDir dir = TempDir.create()) { + Path file = write(dir, "overrides.yaml", yaml("a")); + AtomicLong clock = new AtomicLong(); + Recorder recorder = new Recorder(); + FileDataReloader r = reloader(new CountingLoader(clock::get, file), recorder, HOUR); + r.reloadNow(); + + InPlaceWrites.truncate(file); + r.reloadNow(); + for (int i = 0; i < 4; i++) { + clock.addAndGet(Duration.ofMinutes(60).toNanos()); + writeAgain(file, i % 2 == 0 ? " " : " "); + r.reloadNow(); + } + clock.addAndGet(Duration.ofMinutes(48).toNanos()); + writeAgain(file, " "); + r.reloadNow(); + + // The file's delay would be over in an hour, but its bound is over in 12 minutes. + assertEquals(Collections.singletonList("a"), recorder.applied); + assertEquals(Duration.ofMinutes(12).toNanos() + TENTH, r.getLastRequestedRetryNanos()); + } + } + + @Test + public void holdOfSeveralFilesAsksForTheRetryWhenTheFirstIsDue() throws Exception { + try (TempDir dir = TempDir.create()) { + Path a = write(dir, "a.yaml", yaml("a")); + Path b = write(dir, "b.yaml", "flagValues:\n other: \"b\"\n"); + AtomicLong clock = new AtomicLong(); + FileDataReloader r = reloader(new CountingLoader(clock::get, a, b), new Recorder(), HOUR); + r.reloadNow(); + + InPlaceWrites.truncate(a); + r.reloadNow(); + clock.addAndGet(Duration.ofMinutes(20).toNanos()); + InPlaceWrites.truncate(b); + r.reloadNow(); + assertEquals(Duration.ofMinutes(40).toNanos() + TENTH, r.getLastRequestedRetryNanos()); + } + } + + @Test + public void holdStartedByAChangeSignalAsksForTheRetryWhenTheDelayIsOver() throws Exception { + try (TempDir dir = TempDir.create()) { + Path file = write(dir, "overrides.yaml", yaml("a")); + AtomicLong clock = new AtomicLong(); + CountingLoader loader = new CountingLoader(clock::get, file); + FileDataReloader r = reloader(loader, new Recorder(), HOUR); + r.reloadNow(); + + InPlaceWrites.truncate(file); + r.trigger(); + loader.awaitFinished(2); + long deadline = System.currentTimeMillis() + WAIT_MILLIS; + while (r.getLastRequestedRetryNanos() != HOUR.toNanos() + TENTH && System.currentTimeMillis() < deadline) { + Thread.sleep(5); + } + assertEquals(HOUR.toNanos() + TENTH, r.getLastRequestedRetryNanos()); + assertThat(logCapture.getMessageStrings(), hasItem(readAgainLog(file))); + } + } + + @Test + public void failedLoadAsksForTheRetryDelay() throws Exception { + try (TempDir dir = TempDir.create()) { + Path file = write(dir, "overrides.yaml", yaml("a")); + AtomicLong clock = new AtomicLong(); + Recorder recorder = new Recorder(); + FileDataReloader r = reloader(new CountingLoader(clock::get, file), recorder, HOUR); + r.reloadNow(); + + InPlaceWrites.rewrite(file, "flagValues: [", () -> { }); + r.reloadNow(); + assertEquals(1, recorder.errors.size()); + assertEquals(HOUR.toNanos(), r.getLastRequestedRetryNanos()); + } + } + @Test public void holdEndsExactlyAfterTheRetryDelay() throws Exception { try (TempDir dir = TempDir.create()) { @@ -189,6 +303,34 @@ public void holdEndsExactlyAfterTheRetryDelay() throws Exception { } } + @Test + public void fileRewrittenWithTheSameContentIsHeldBackAgainLater() throws Exception { + // A tool rewrites the file in place now and then with the same content. The load after a rewrite + // is skipped as unchanged, and that must start the file's bound over as an application does, or + // a rewrite five retry delays later would be applied while the file is empty. + try (TempDir dir = TempDir.create()) { + Path file = write(dir, "overrides.yaml", yaml("a")); + AtomicLong clock = new AtomicLong(); + CountingLoader loader = new CountingLoader(clock::get, file); + Recorder recorder = new Recorder(); + FileDataReloader r = reloader(loader, recorder, HOUR); + r.reloadNow(); + + InPlaceWrites.truncate(file); + r.reloadNow(); + InPlaceWrites.rewrite(file, yaml("a"), () -> { }); + r.reloadNow(); + assertEquals(3, loader.finished.get()); + assertEquals(Collections.singletonList("a"), recorder.applied); + + clock.addAndGet(HOUR.toNanos() * 6); + InPlaceWrites.truncate(file); + r.reloadNow(); + assertEquals(Collections.singletonList("a"), recorder.applied); + assertTrue(recorder.errors.isEmpty()); + } + } + @Test public void changesToOtherFilesWaitWhileAFileIsHeld() throws Exception { // A reload applies all the files or none. diff --git a/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/integrations/FileDataReloaderTest.java b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/integrations/FileDataReloaderTest.java index 6b0d9834..8ded2803 100644 --- a/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/integrations/FileDataReloaderTest.java +++ b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/integrations/FileDataReloaderTest.java @@ -582,7 +582,7 @@ public void emptiedFileIsReadAgainAfterTheRetryDelayWithoutReportingAFailure() t assertTrue(handler.errors.isEmpty()); List messages = logCapture.getMessageStrings(); assertThat(messages, hasItem(READ_AGAIN_MESSAGE)); - assertThat(messages, hasItem("DEBUG:Reading flag data again after finding an empty file")); + assertThat(messages, hasItem("DEBUG:Reading flag data again after a file was found empty")); assertThat(messages, not(hasItem("DEBUG:Retrying flag data load after earlier failure"))); for (String message : messages) { assertFalse(message, message.startsWith("ERROR:")); @@ -603,6 +603,7 @@ public void holdIsRetriedAfterTheTimeTheLoaderAsksFor() throws Exception { awaitSize(handler.applied, 2); assertEquals(rewritten, handler.applied.get(1)); assertEquals(3, loader.calls.get()); + assertEquals(Duration.ofMillis(30).toNanos(), r.getLastRequestedRetryNanos()); } @Test @@ -620,6 +621,7 @@ public void failureAfterAHoldIsRetriedAfterTheRetryDelay() throws Exception { Thread.sleep(300); assertEquals(3, loader.calls.get()); assertEquals(1, handler.applied.size()); + assertEquals(Duration.ofHours(1).toNanos(), r.getLastRequestedRetryNanos()); } @Test @@ -678,7 +680,7 @@ public void failureAfterAReadAgainIsRetriedAsAFailure() throws Exception { assertEquals(4, loader.calls.get()); assertEquals(1, handler.errors.size()); List messages = logCapture.getMessageStrings(); - assertThat(messages, hasItem("DEBUG:Reading flag data again after finding an empty file")); + assertThat(messages, hasItem("DEBUG:Reading flag data again after a file was found empty")); assertThat(messages, hasItem("DEBUG:Retrying flag data load after earlier failure")); } diff --git a/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/integrations/OverrideFileLoaderTest.java b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/integrations/OverrideFileLoaderTest.java index e96bdfb2..86b3cbf9 100644 --- a/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/integrations/OverrideFileLoaderTest.java +++ b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/integrations/OverrideFileLoaderTest.java @@ -480,7 +480,7 @@ public void holdAsksForARetryWhenTheFirstDelayEnds() throws Exception { } @Test - public void holdAsksForARetryWhenTheRunOfHoldsEnds() throws Exception { + public void holdAsksForARetryWhenTheBoundEnds() throws Exception { try (TempDir dir = TempDir.create()) { Path file = write(dir, "overrides.yaml", YAML_DOC); TestClock clock = new TestClock(); @@ -494,8 +494,8 @@ public void holdAsksForARetryWhenTheRunOfHoldsEnds() throws Exception { writeAgain(file, " "); assertHeld(loader, file); } - // 4.6 retry delays into the run, the file is written again. Its own delay would end at 5.6, the - // run's at 5.0. + // 4.6 retry delays after the first hold, the file is written again. Its delay would end at 5.6, + // its bound at 5.0. clock.advance(1.0); writeAgain(file, " "); assertEquals(0.5, assertHeld(loader, file), 1e-9); @@ -525,7 +525,8 @@ public void contentThatIsOnlyWhiteSpaceOrAByteOrderMarkCountsAsEmpty() throws Ex public void fileEmptiedAgainWithTheSameStateAfterContentStartsANewDelay() throws Exception { // File system timestamps are coarse, so a file can be emptied again with the modification time // and size that it had when it was last found empty. A load that read content in between shows - // that this is a new emptying. + // that this is a new emptying, so its delay starts over. Nothing is applied in between: the test + // does not accept the result of the load that reads the content. try (TempDir dir = TempDir.create()) { Path file = write(dir, "overrides.yaml", YAML_DOC); TestClock clock = new TestClock(); @@ -535,13 +536,17 @@ public void fileEmptiedAgainWithTheSameStateAfterContentStartsANewDelay() throws InPlaceWrites.truncate(file); java.nio.file.attribute.FileTime emptiedAt = Files.getLastModifiedTime(file); assertHeld(loader, file); - clock.advance(2); + clock.advance(0.8); InPlaceWrites.rewrite(file, YAML_DOC, () -> { }); assertEquals(1, loader.load(RETRY).getFlagCount()); + clock.advance(0.4); InPlaceWrites.truncate(file); Files.setLastModifiedTime(file, emptiedAt); assertHeld(loader, file); + // The new delay is still running a little later. + clock.advance(0.2); + assertHeld(loader, file); } } @@ -692,7 +697,7 @@ public void newWriteOfALinkedFileStartsTheDelayAgain() throws Exception { } @Test - public void fileThatKeepsBeingWrittenIsParsedAfterTheRunOfHolds() throws Exception { + public void fileThatKeepsBeingWrittenIsParsedAfterItsBound() throws Exception { try (TempDir dir = TempDir.create()) { Path file = write(dir, "overrides.yaml", YAML_DOC); TestClock clock = new TestClock(); @@ -701,8 +706,8 @@ public void fileThatKeepsBeingWrittenIsParsedAfterTheRunOfHolds() throws Excepti InPlaceWrites.truncate(file); assertHeld(loader, file); - // Every load finds a new write, so the delay keeps starting again, up to the limit of five - // retry delays for the run of loads that hold files back. + // Every load finds a new write, so the delay keeps starting again, up to the bound of five retry + // delays from the first hold. for (int i = 0; i < 5; i++) { clock.advance(0.9); truncateAgain(file); @@ -715,9 +720,10 @@ public void fileThatKeepsBeingWrittenIsParsedAfterTheRunOfHolds() throws Excepti } @Test - public void filesRewrittenInTurnAreParsedAfterTheRunOfHolds() throws Exception { + public void filesRewrittenInTurnAreParsedAfterTheirBound() throws Exception { // Each load finds one of the files empty and the other with content, so each file's own delay - // starts again whenever it is found empty. The run of holds still ends. + // starts again whenever it is found empty. A read with content keeps the file's bound, so it + // still ends. try (TempDir dir = TempDir.create()) { Path a = write(dir, "a.yaml", YAML_DOC); Path b = write(dir, "b.yaml", "flagValues:\n other: \"value\"\n"); @@ -743,7 +749,9 @@ public void filesRewrittenInTurnAreParsedAfterTheRunOfHolds() throws Exception { } @Test - public void loadThatHoldsNothingBackEndsTheRun() throws Exception { + public void readWithContentKeepsTheBound() throws Exception { + // A load that reads the file with content but applies nothing, here because the test does not + // accept the result, keeps the file's bound. try (TempDir dir = TempDir.create()) { Path file = write(dir, "overrides.yaml", YAML_DOC); TestClock clock = new TestClock(); @@ -757,21 +765,43 @@ public void loadThatHoldsNothingBackEndsTheRun() throws Exception { truncateAgain(file); assertHeld(loader, file); } - // A load that reads the file with content ends the run, so the next emptying starts a new one. writeAgain(file, YAML_DOC); assertEquals(1, loader.load(RETRY).getFlagCount()); truncateAgain(file); - clock.advance(0.1); + clock.advance(0.5); + assertEquals(0, loader.load(RETRY).getFlagCount()); + } + } + + @Test + public void applyingDataStartsTheBoundOver() throws Exception { + try (TempDir dir = TempDir.create()) { + Path file = write(dir, "overrides.yaml", YAML_DOC); + TestClock clock = new TestClock(); + OverrideFileLoader loader = loader(clock, file); + loadAndAccept(loader); + + InPlaceWrites.truncate(file); assertHeld(loader, file); + for (int i = 0; i < 5; i++) { + clock.advance(0.9); + truncateAgain(file); + assertHeld(loader, file); + } + writeAgain(file, YAML_DOC); + assertEquals(1, loadAndAccept(loader).getFlagCount()); + truncateAgain(file); clock.advance(0.5); assertHeld(loader, file); + clock.advance(0.4); + assertHeld(loader, file); } } @Test - public void failingLoadEndsTheRun() throws Exception { - // A JSON file that stays empty is parsed after the delay and fails, which ends the run. When it is - // later rewritten in place, the new truncation is held back again instead of reported. + public void fileThatStayedEmptyForTheDelayGetsAFreshBound() throws Exception { + // A JSON file that stays empty is parsed after the delay and fails. When it is later rewritten in + // place, the new truncation is held back again instead of reported. try (TempDir dir = TempDir.create()) { Path file = write(dir, "overrides.json", JSON_DOC); TestClock clock = new TestClock(); @@ -792,6 +822,133 @@ public void failingLoadEndsTheRun() throws Exception { } } + @Test + public void failingOtherFileDoesNotRestartTheBound() throws Exception { + // a.yaml keeps being written while empty. Another file fails to load now and then, which must not + // give a.yaml a fresh bound. + try (TempDir dir = TempDir.create()) { + Path a = write(dir, "a.yaml", YAML_DOC); + String other = "{\"flagValues\":{\"other\":\"value\"}}"; + Path c = write(dir, "c.json", other); + TestClock clock = new TestClock(); + OverrideFileLoader loader = loader(clock, a, c); + loadAndAccept(loader); + + InPlaceWrites.truncate(a); + assertHeld(loader, a); + for (int i = 1; i < 10; i++) { + clock.advance(0.5); + writeAgain(a, " "); + if (i % 2 == 1) { + InPlaceWrites.rewrite(c, "{bad", () -> { }); + try { + loader.load(RETRY); + fail("expected exception"); + } catch (FileDataException e) { + assertThat(e.getMessage(), containsString(c.toString())); + } + } else { + InPlaceWrites.rewrite(c, other, () -> { }); + assertHeld(loader, a); + } + } + clock.advance(0.5); + writeAgain(a, " "); + InPlaceWrites.rewrite(c, other, () -> { }); + LoadResult result = loader.load(RETRY); + assertEquals(1, result.getFlagCount()); + assertFalse(result.getFiles().get(0).hasContent()); + } + } + + @Test + public void anotherFilesBoundDoesNotCutShortARewrite() throws Exception { + // b.yaml keeps being written while empty, and reaches its bound while a.yaml is in the middle of an + // ordinary rewrite. a.yaml is still held back. + try (TempDir dir = TempDir.create()) { + Path a = write(dir, "a.yaml", YAML_DOC); + Path b = write(dir, "b.yaml", "flagValues:\n other: \"value\"\n"); + TestClock clock = new TestClock(); + OverrideFileLoader loader = loader(clock, a, b); + loadAndAccept(loader); + + InPlaceWrites.truncate(b); + assertHeld(loader, b); + for (int i = 1; i <= 9; i++) { + clock.advance(0.5); + writeAgain(b, " "); + if (i == 9) { + InPlaceWrites.truncate(a); + assertHeld(loader, a, b); + } else { + assertHeld(loader, b); + } + } + // b's bound is over now, but a has been empty for half a retry delay only. + clock.advance(0.5); + writeAgain(b, " "); + assertHeld(loader, a); + + clock.advance(0.25); + InPlaceWrites.rewrite(a, YAML_DOC, () -> { }); + LoadResult result = loader.load(RETRY); + assertEquals(1, result.getFlagCount()); + assertTrue(result.getFiles().get(0).hasContent()); + } + } + + @Test + public void holdAsksForTheRestOfTheDelayAfterASlowReadOfAnotherFile() throws Exception { + // a.yaml is held back, and the read of b.yaml after it takes half a retry delay. The retry comes + // when a.yaml's delay is over, counted from when the load asks for it. + try (TempDir dir = TempDir.create()) { + Path a = write(dir, "a.yaml", YAML_DOC); + Path b = write(dir, "b.yaml", "flagValues:\n other: \"value\"\n"); + TestClock clock = new TestClock(); + AtomicLong slowReadOf = new AtomicLong(); + OverrideFileLoader loader = new OverrideFileLoader(Arrays.asList(a, b), FileData.DuplicateKeysHandling.FAIL, + clock.nanos::get, path -> { + byte[] raw = Files.readAllBytes(path); + if (path.equals(b) && slowReadOf.getAndSet(0) != 0) { + clock.advance(0.5); + } + return raw; + }); + loadAndAccept(loader); + + InPlaceWrites.truncate(a); + slowReadOf.set(1); + assertEquals(0.6, assertHeld(loader, a), 1e-9); + } + } + + @Test + public void slowReadOfAnotherFileDoesNotShortenTheDelay() throws Exception { + // The read of b.yaml, which comes first, takes most of a retry delay. a.yaml's delay counts from + // when a.yaml was read. + try (TempDir dir = TempDir.create()) { + Path a = write(dir, "a.yaml", YAML_DOC); + Path b = write(dir, "b.yaml", "flagValues:\n other: \"value\"\n"); + TestClock clock = new TestClock(); + AtomicLong slowReadOf = new AtomicLong(); + OverrideFileLoader loader = new OverrideFileLoader(Arrays.asList(b, a), FileData.DuplicateKeysHandling.FAIL, + clock.nanos::get, path -> { + byte[] raw = Files.readAllBytes(path); + if (path.equals(b) && slowReadOf.getAndSet(0) != 0) { + clock.advance(0.94); + } + return raw; + }); + loadAndAccept(loader); + + InPlaceWrites.truncate(a); + slowReadOf.set(1); + assertHeld(loader, a); + clock.advance(0.1); + assertHeld(loader, a); + } + } + @Test public void withoutRetriesAnEmptiedFileIsParsedRightAway() throws Exception { try (TempDir dir = TempDir.create()) { From cec4530a4fc9b7b94e946391c64d00ca6eec41c5 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Tue, 3 Nov 2026 04:17:26 +0000 Subject: [PATCH 27/30] fix: Start a held-back override file's bound over only at a load that holds nothing back A file that stayed empty for the whole delay cleared its own bound while another file kept the loads holding, so two files emptied in staggered turns were held back without end. A file held back during a long failure of another file kept its bound, so a later deploy that fixed the other file while rewriting this one applied it empty. A file's bound now starts over only at a load that holds nothing back, for every file that load read, whether the load is applied, skipped or fails. A file that a failing load did not get to keeps its bound. --- .../integrations/OverrideFileLoader.java | 156 ++++++++------- .../integrations/OverrideFileLoaderTest.java | 185 +++++++++++++++++- 2 files changed, 267 insertions(+), 74 deletions(-) diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/OverrideFileLoader.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/OverrideFileLoader.java index 86e2ab5f..f012345f 100644 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/OverrideFileLoader.java +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/OverrideFileLoader.java @@ -54,8 +54,8 @@ * The data in effect stays, and the reloader reads the files again when the exception says. A file is * held back until it has stayed empty, without being written, for one retry delay, and for at most * five retry delays from the first load that held it back, so that a file that keeps being rewritten - * cannot hold the changes back for longer. The bound is per file and starts over when data is - * applied. A file that never contributed content, such as a placeholder, and a file whose empty state + * cannot hold the changes back for longer. The bound is per file, and it starts over at a load that + * holds nothing back. A file that never contributed content, such as a placeholder, and a file whose empty state * is in effect are parsed right away. The reloader calls {@link #load(Duration)} and * {@link #accepted(LoadResult)} one call at a time, so this state needs no synchronization. */ @@ -177,7 +177,7 @@ byte[] getContentHash() { } /** - * A file whose content is in effect and that loads have found empty since data was last applied. + * A file whose content is in effect and that loads have found empty. * Times are from the monotonic clock. */ private static final class EmptyFile { @@ -187,8 +187,8 @@ private static final class EmptyFile { // The state of the file then: its modification time and size, read through any symbolic link. It // is only compared for equality. FileDataPoller.FileState state; - // When a load first held the file back, or null if none has since the file last stayed empty for - // the whole retry delay. A read with content and a failed load keep it, so that a file that keeps + // When a load first held the file back, or null if no load has since the last load that read the + // file and held nothing back. Loads that hold another file back keep it, so that a file that keeps // being rewritten stays bounded. Long firstHeldNanos; } @@ -200,7 +200,7 @@ private static final class EmptyFile { // accepted(LoadResult). A file that is not listed did not. private final Map appliedHadContent = new HashMap<>(); private final FileReader reader; - // The files whose content is in effect and that loads have found empty since data was last applied. + // The files whose content is in effect and that loads have found empty. private final Map emptyFiles = new HashMap<>(); /** @@ -250,63 +250,82 @@ public LoadResult load(Duration retryDelay) throws FileDataException, EmptiedFil Map> data = new HashMap<>(); List files = new ArrayList<>(paths.size()); List heldBack = new ArrayList<>(); - for (Path path : paths) { - byte[] raw; - try { - raw = reader.read(path); - } catch (NoSuchFileException e) { - // Absence is a valid state, not a failure. - files.add(new FileSummary(path, false, false, 0, 0)); - continue; - } catch (IOException e) { - throw new FileDataException("file " + path + ": unable to read file", e); - } - // Content that the parser reads as no document, such as YAML with only a comment, has no - // entries, and a read can see that much of a file that is being written. Content with only - // white space is invalid JSON, so it is parsed only when the file is not held back. - FlagFileRep document = null; - boolean hasContent = hasContent(raw); - if (hasContent) { - document = parse(path, raw); - hasContent = document != FlagFileRep.NO_DOCUMENT; - } - if (hasContent) { - EmptyFile empty = emptyFiles.get(path); - if (empty != null) { - empty.sinceNanos = null; + // The files that this load read and did not hold back: read with content, parsed empty, or + // found missing. + List read = new ArrayList<>(); + try { + for (Path path : paths) { + byte[] raw; + try { + raw = reader.read(path); + } catch (NoSuchFileException e) { + // Absence is a valid state, not a failure. + read.add(path); + files.add(new FileSummary(path, false, false, 0, 0)); + continue; + } catch (IOException e) { + throw new FileDataException("file " + path + ": unable to read file", e); } - } else if (shouldHoldBack(path, retryDelay, nanoClock.getAsLong())) { - // The clock is read after this file's own read, so a slow read of another file does not - // shorten its delay. The other files are still read, so that each emptied file starts its - // delay in this load, and a failure of another file still fails the load. - heldBack.add(path); - continue; - } - // One read feeds both the digest and the parse, so the skip-unchanged digest can never disagree - // with the content that was applied. - if (digest != null) { - digest.update(raw); - digest.update((byte) 0); - } - if (document == null) { - document = parse(path, raw); + // Content that the parser reads as no document, such as YAML with only a comment, has no + // entries, and a read can see that much of a file that is being written. Content with only + // white space is invalid JSON, so it is parsed only when the file is not held back. + FlagFileRep document = null; + boolean hasContent = hasContent(raw); + if (hasContent) { + document = parse(path, raw); + hasContent = document != FlagFileRep.NO_DOCUMENT; + } + if (hasContent) { + EmptyFile empty = emptyFiles.get(path); + if (empty != null) { + empty.sinceNanos = null; + } + } else if (shouldHoldBack(path, retryDelay, nanoClock.getAsLong())) { + // The clock is read after this file's own read, so a slow read of another file does not + // shorten its delay. The other files are still read, so that each emptied file starts its + // delay in this load, and a failure of another file still fails the load. + heldBack.add(path); + continue; + } + read.add(path); + // One read feeds both the digest and the parse, so the skip-unchanged digest can never disagree + // with the content that was applied. + if (digest != null) { + digest.update(raw); + digest.update((byte) 0); + } + if (document == null) { + document = parse(path, raw); + } + int[] added = addEntries(data, path, document); + files.add(new FileSummary(path, true, hasContent, added[0], added[1])); } - int[] added = addEntries(data, path, document); - files.add(new FileSummary(path, true, hasContent, added[0], added[1])); - } - if (!heldBack.isEmpty()) { - throw new EmptiedFileException(heldBack, holdBack(heldBack, retryDelay, nanoClock.getAsLong())); - } + if (!heldBack.isEmpty()) { + throw new EmptiedFileException(heldBack, holdBack(heldBack, retryDelay, nanoClock.getAsLong())); + } - ImmutableList.Builder>> all = ImmutableList.builder(); - for (Map.Entry> e : data.entrySet()) { - all.add(new AbstractMap.SimpleEntry<>(e.getKey(), new KeyedItems<>(ImmutableList.copyOf(e.getValue().entrySet())))); + ImmutableList.Builder>> all = ImmutableList.builder(); + for (Map.Entry> e : data.entrySet()) { + all.add(new AbstractMap.SimpleEntry<>(e.getKey(), new KeyedItems<>(ImmutableList.copyOf(e.getValue().entrySet())))); + } + Map flags = data.get(FEATURES); + Map segments = data.get(SEGMENTS); + return new LoadResult(all.build(), files, flags == null ? 0 : flags.size(), + segments == null ? 0 : segments.size(), digest == null ? null : digest.digest()); + } finally { + if (heldBack.isEmpty()) { + // A load that holds nothing back, whether it is applied, skipped as unchanged, or fails, + // starts the bound over for every file that it read: such a file is no longer held, so its + // next rewrite is a new one. A file that the load did not get to keeps its bound. + for (Path path : read) { + EmptyFile empty = emptyFiles.get(path); + if (empty != null) { + empty.firstHeldNanos = null; + } + } + } } - Map flags = data.get(FEATURES); - Map segments = data.get(SEGMENTS); - return new LoadResult(all.build(), files, flags == null ? 0 : flags.size(), - segments == null ? 0 : segments.size(), digest == null ? null : digest.digest()); } private static FlagFileRep parse(Path path, byte[] raw) throws FileDataException { @@ -371,8 +390,6 @@ public void accepted(LoadResult result) { for (FileSummary file : result.getFiles()) { appliedHadContent.put(file.getPath(), file.hasContent()); } - // The data in effect has changed or been confirmed, so every file's delay and bound start over. - emptyFiles.clear(); } // Decides whether a file that a load found empty is held back. A file whose content is in effect @@ -383,12 +400,14 @@ public void accepted(LoadResult result) { // over. A file that keeps being written while loads find it empty would be held back without end, so // each file is held back for at most MAX_HOLD_RETRY_DELAYS retry delays from the first load that held // it back. The bound is the file's own, so one file reaching it does not cut short another file's - // rewrite, and it is kept across reads with content and failed loads, so files rewritten in turn and - // a failing other file cannot restart it. It starts over when data is applied, or when the file has - // stayed empty for the whole delay. A file whose content is not in effect, such as a placeholder that - // has always been empty, or a file whose empty state was applied, is parsed right away. A zero retry - // delay, which means that the reloader does not retry, holds nothing back: nothing would read the - // file again. Elapsed times are differences of monotonic clock readings, which do not overflow. + // rewrite. It starts over only at a load that holds nothing back, for every file that load read, + // whatever the load's outcome. Loads that hold another file back keep it, which bounds files + // rewritten in turn or emptied in staggered turns, and a file that was read during a long failure + // of another file has a fresh bound when that failure ends. A file whose content is not in effect, + // such as a placeholder that has always been empty, or a file whose empty state was applied, is + // parsed right away. A zero retry delay, which means that the reloader does not retry, holds nothing + // back: nothing would read the file again. Elapsed times are differences of monotonic clock + // readings, which do not overflow. private boolean shouldHoldBack(Path path, Duration retryDelay, long now) { if (!Boolean.TRUE.equals(appliedHadContent.get(path))) { return false; @@ -401,8 +420,9 @@ private boolean shouldHoldBack(Path path, Duration retryDelay, long now) { } long delay = retryDelay.toNanos(); if (now - empty.sinceNanos >= delay) { - // The file has stayed empty for the whole delay, so it is applied as it is. - empty.firstHeldNanos = null; + // The file has stayed empty for the whole delay, so it is parsed as it is. Its bound starts over + // only at a load that holds nothing back, so that files that stay empty in staggered turns, each + // keeping the loads holding while the other settles, cannot hold the changes back without end. return false; } if (empty.firstHeldNanos != null && now - empty.firstHeldNanos >= MAX_HOLD_RETRY_DELAYS * delay) { diff --git a/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/integrations/OverrideFileLoaderTest.java b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/integrations/OverrideFileLoaderTest.java index 86b3cbf9..6a725222 100644 --- a/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/integrations/OverrideFileLoaderTest.java +++ b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/integrations/OverrideFileLoaderTest.java @@ -749,9 +749,9 @@ public void filesRewrittenInTurnAreParsedAfterTheirBound() throws Exception { } @Test - public void readWithContentKeepsTheBound() throws Exception { - // A load that reads the file with content but applies nothing, here because the test does not - // accept the result, keeps the file's bound. + public void loadThatHoldsNothingBackStartsTheBoundOver() throws Exception { + // A load that reads the file with content and holds nothing back starts the file's bound over, + // although nothing is applied here because the test does not accept the result. try (TempDir dir = TempDir.create()) { Path file = write(dir, "overrides.yaml", YAML_DOC); TestClock clock = new TestClock(); @@ -769,7 +769,9 @@ public void readWithContentKeepsTheBound() throws Exception { assertEquals(1, loader.load(RETRY).getFlagCount()); truncateAgain(file); clock.advance(0.5); - assertEquals(0, loader.load(RETRY).getFlagCount()); + assertHeld(loader, file); + clock.advance(0.4); + assertHeld(loader, file); } } @@ -800,8 +802,9 @@ public void applyingDataStartsTheBoundOver() throws Exception { @Test public void fileThatStayedEmptyForTheDelayGetsAFreshBound() throws Exception { - // A JSON file that stays empty is parsed after the delay and fails. When it is later rewritten in - // place, the new truncation is held back again instead of reported. + // A JSON file that stays empty is parsed after the delay and fails. That load holds nothing back, + // so the file's bound starts over, and when it is later rewritten in place, the new truncation is + // held back again instead of reported. try (TempDir dir = TempDir.create()) { Path file = write(dir, "overrides.json", JSON_DOC); TestClock clock = new TestClock(); @@ -861,6 +864,176 @@ public void failingOtherFileDoesNotRestartTheBound() throws Exception { } } + // Loads the files and applies the result, as the reloader does, or reports that the load held a + // file back. + private static boolean loadApplies(OverrideFileLoader loader) throws Exception { + try { + loader.accepted(loader.load(RETRY)); + return true; + } catch (EmptiedFileException e) { + return false; + } + } + + @Test + public void filesEmptiedInStaggeredTurnsAreAppliedAfterTheBound() throws Exception { + // Two files stay empty and are truncated again in turn, so that whenever one of them has stayed + // empty for the whole delay, the other is still within its delay and holds the load back. A file + // that settles so does not get a fresh bound while the other is held back. + try (TempDir dir = TempDir.create()) { + Path a = write(dir, "a.yaml", YAML_DOC); + Path b = write(dir, "b.yaml", "flagValues:\n other: \"value\"\n"); + TestClock clock = new TestClock(); + OverrideFileLoader loader = loader(clock, a, b); + loadAndAccept(loader); + + InPlaceWrites.truncate(a); + assertHeld(loader, a); + boolean applied = false; + for (int i = 1; i <= 14 && !applied; i++) { + clock.advance(0.9); + if (i == 1) { + InPlaceWrites.truncate(b); + } else { + truncateAgain(i % 2 == 1 ? b : a); + } + applied = loadApplies(loader); + if (!applied) { + // The retry that the hold arms, a little after the other file's delay is over. + clock.advance(0.2); + applied = loadApplies(loader); + clock.advance(-0.2); + } + } + assertTrue("the files were held back without end", applied); + } + } + + @Test + public void boundOfAFileHeldBackDuringAFailureStartsOverOnceItIsReadAgain() throws Exception { + // While b.json fails to load, a.yaml is rewritten once and its gap is held back. Later, b.json is + // fixed while a.yaml is in the middle of another rewrite, which is held back with a bound of its own. + try (TempDir dir = TempDir.create()) { + Path a = write(dir, "a.yaml", YAML_DOC); + String other = "{\"flagValues\":{\"other\":\"value\"}}"; + Path b = write(dir, "b.json", other); + TestClock clock = new TestClock(); + OverrideFileLoader loader = loader(clock, a, b); + loadAndAccept(loader); + + InPlaceWrites.rewrite(b, "{bad", () -> { }); + InPlaceWrites.truncate(a); + assertLoadFailsFor(loader, b); + InPlaceWrites.rewrite(a, "flagValues:\n flag: \"other value\"\n", () -> { }); + assertLoadFailsFor(loader, b); + clock.advance(6); + assertLoadFailsFor(loader, b); + + InPlaceWrites.rewrite(b, other, () -> { }); + InPlaceWrites.truncate(a); + assertHeld(loader, a); + } + } + + @Test + public void fileFoundMissingDuringAFailureGetsAFreshBound() throws Exception { + // a.yaml is held back, then deleted while b.json fails to load. A load that finds it missing and + // holds nothing back starts its bound over, so when it comes back empty much later, while b.json + // is fixed, it is held back again. + try (TempDir dir = TempDir.create()) { + Path a = write(dir, "a.yaml", YAML_DOC); + String other = "{\"flagValues\":{\"other\":\"value\"}}"; + Path b = write(dir, "b.json", other); + TestClock clock = new TestClock(); + OverrideFileLoader loader = loader(clock, a, b); + loadAndAccept(loader); + + InPlaceWrites.truncate(a); + long emptiedAt = Files.getLastModifiedTime(a).toMillis(); + assertHeld(loader, a); + Files.delete(a); + InPlaceWrites.rewrite(b, "{bad", () -> { }); + assertLoadFailsFor(loader, b); + clock.advance(6); + + InPlaceWrites.rewrite(b, other, () -> { }); + // File system timestamps are coarse; the file must not look as it did when it was found empty. + do { + Thread.sleep(20); + Files.write(a, new byte[0]); + } while (Files.getLastModifiedTime(a).toMillis() == emptiedAt); + assertHeld(loader, a); + } + } + + @Test + public void fileThatAFailingLoadDidNotReadKeepsItsBound() throws Exception { + // c.json comes first and fails now and then, so those loads never read a.yaml, which keeps being + // written while empty. They do not start a.yaml's bound over. + try (TempDir dir = TempDir.create()) { + String other = "{\"flagValues\":{\"other\":\"value\"}}"; + Path c = write(dir, "c.json", other); + Path a = write(dir, "a.yaml", YAML_DOC); + TestClock clock = new TestClock(); + OverrideFileLoader loader = loader(clock, c, a); + loadAndAccept(loader); + + InPlaceWrites.truncate(a); + assertHeld(loader, a); + for (int i = 1; i <= 5; i++) { + clock.advance(0.9); + writeAgain(a, i % 2 == 0 ? " " : " "); + if (i % 2 == 1) { + InPlaceWrites.rewrite(c, "{bad", () -> { }); + assertLoadFailsFor(loader, c); + } else { + InPlaceWrites.rewrite(c, other, () -> { }); + assertHeld(loader, a); + } + } + clock.advance(0.9); + writeAgain(a, " "); + InPlaceWrites.rewrite(c, other, () -> { }); + assertEquals(1, loader.load(RETRY).getFlagCount()); + } + } + + @Test + public void holdAsksForTheRetryFromTheEndOfTheLoad() throws Exception { + // The read of the file after the held one takes longer than what is left of the held file's + // delay, so the retry is due right away, after the margin. + try (TempDir dir = TempDir.create()) { + Path a = write(dir, "a.yaml", YAML_DOC); + Path b = write(dir, "b.yaml", "flagValues:\n other: \"value\"\n"); + TestClock clock = new TestClock(); + AtomicLong slowReadOf = new AtomicLong(); + OverrideFileLoader loader = new OverrideFileLoader(Arrays.asList(a, b), FileData.DuplicateKeysHandling.FAIL, + clock.nanos::get, path -> { + byte[] raw = Files.readAllBytes(path); + if (path.equals(b) && slowReadOf.getAndSet(0) != 0) { + clock.advance(0.1); + } + return raw; + }); + loadAndAccept(loader); + + InPlaceWrites.truncate(a); + assertEquals(1.1, assertHeld(loader, a), 1e-9); + clock.advance(0.95); + slowReadOf.set(1); + assertEquals(0.1, assertHeld(loader, a), 1e-9); + } + } + + private static void assertLoadFailsFor(OverrideFileLoader loader, Path file) throws Exception { + try { + loader.load(RETRY); + fail("expected the load to fail"); + } catch (FileDataException e) { + assertThat(e.getMessage(), containsString(file.toString())); + } + } + @Test public void anotherFilesBoundDoesNotCutShortARewrite() throws Exception { // b.yaml keeps being written while empty, and reaches its bound while a.yaml is in the middle of an From 6c7909f160ae10c4d190b7bf52dd55cdd2e491e0 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Tue, 3 Nov 2026 05:31:37 +0000 Subject: [PATCH 28/30] fix: Start the bound over for an override file whose read or parse fails A load that held nothing back started the bound over only for the files it read without a failure, so a file that failed to read or parse right after a hold kept its old bound. Saving a YAML file in place with a mistake and then the fix in place a few seconds later applied the fix's gap empty. A load now records every file it gets to and does not hold back, including the one whose read or parse fails, and starts their bounds over when it holds nothing back. A read with content, even content that fails to parse, and a missing file end the file's empty state, so its next emptying starts a new delay even where a coarse modification time makes it look the same. --- .../integrations/OverrideFileLoader.java | 50 ++++-- .../integrations/OverrideFileLoaderTest.java | 150 ++++++++++++++++-- 2 files changed, 169 insertions(+), 31 deletions(-) diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/OverrideFileLoader.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/OverrideFileLoader.java index f012345f..43cce040 100644 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/OverrideFileLoader.java +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/OverrideFileLoader.java @@ -182,12 +182,12 @@ byte[] getContentHash() { */ private static final class EmptyFile { // Since when the file has been empty without being written, or null after a load read it with - // content. + // content, even content that fails to parse, or found it missing. Long sinceNanos; // The state of the file then: its modification time and size, read through any symbolic link. It // is only compared for equality. FileDataPoller.FileState state; - // When a load first held the file back, or null if no load has since the last load that read the + // When a load first held the file back, or null if no load has since the last load that got to the // file and held nothing back. Loads that hold another file back keep it, so that a file that keeps // being rewritten stays bounded. Long firstHeldNanos; @@ -250,9 +250,9 @@ public LoadResult load(Duration retryDelay) throws FileDataException, EmptiedFil Map> data = new HashMap<>(); List files = new ArrayList<>(paths.size()); List heldBack = new ArrayList<>(); - // The files that this load read and did not hold back: read with content, parsed empty, or - // found missing. - List read = new ArrayList<>(); + // The files that this load got to and did not hold back: read with content, parsed empty, found + // missing, or failing to read or parse. + List reached = new ArrayList<>(); try { for (Path path : paths) { byte[] raw; @@ -260,10 +260,11 @@ public LoadResult load(Duration retryDelay) throws FileDataException, EmptiedFil raw = reader.read(path); } catch (NoSuchFileException e) { // Absence is a valid state, not a failure. - read.add(path); + noteNotEmpty(path, reached); files.add(new FileSummary(path, false, false, 0, 0)); continue; } catch (IOException e) { + reached.add(path); throw new FileDataException("file " + path + ": unable to read file", e); } // Content that the parser reads as no document, such as YAML with only a comment, has no @@ -272,22 +273,25 @@ public LoadResult load(Duration retryDelay) throws FileDataException, EmptiedFil FlagFileRep document = null; boolean hasContent = hasContent(raw); if (hasContent) { - document = parse(path, raw); + try { + document = parse(path, raw); + } catch (FileDataException e) { + noteNotEmpty(path, reached); + throw e; + } hasContent = document != FlagFileRep.NO_DOCUMENT; } if (hasContent) { - EmptyFile empty = emptyFiles.get(path); - if (empty != null) { - empty.sinceNanos = null; - } + noteNotEmpty(path, reached); } else if (shouldHoldBack(path, retryDelay, nanoClock.getAsLong())) { // The clock is read after this file's own read, so a slow read of another file does not // shorten its delay. The other files are still read, so that each emptied file starts its // delay in this load, and a failure of another file still fails the load. heldBack.add(path); continue; + } else { + reached.add(path); } - read.add(path); // One read feeds both the digest and the parse, so the skip-unchanged digest can never disagree // with the content that was applied. if (digest != null) { @@ -316,9 +320,10 @@ public LoadResult load(Duration retryDelay) throws FileDataException, EmptiedFil } finally { if (heldBack.isEmpty()) { // A load that holds nothing back, whether it is applied, skipped as unchanged, or fails, - // starts the bound over for every file that it read: such a file is no longer held, so its - // next rewrite is a new one. A file that the load did not get to keeps its bound. - for (Path path : read) { + // starts the bound over for every file that it got to, including the one that failed: such a + // file is no longer held, so its next rewrite is a new one. A file after a failing one, which + // the load did not get to, keeps its bound. + for (Path path : reached) { EmptyFile empty = emptyFiles.get(path); if (empty != null) { empty.firstHeldNanos = null; @@ -328,6 +333,17 @@ public LoadResult load(Duration retryDelay) throws FileDataException, EmptiedFil } } + // For a file that a load read with content, including content that fails to parse, or found + // missing: if it was empty before, its empty state has ended, so its next emptying starts a new delay + // even where a coarse modification time makes it look the same. + private void noteNotEmpty(Path path, List reached) { + reached.add(path); + EmptyFile empty = emptyFiles.get(path); + if (empty != null) { + empty.sinceNanos = null; + } + } + private static FlagFileRep parse(Path path, byte[] raw) throws FileDataException { try { return FlagFileParser.selectForPath(path).parse(new ByteArrayInputStream(raw)); @@ -400,8 +416,8 @@ public void accepted(LoadResult result) { // over. A file that keeps being written while loads find it empty would be held back without end, so // each file is held back for at most MAX_HOLD_RETRY_DELAYS retry delays from the first load that held // it back. The bound is the file's own, so one file reaching it does not cut short another file's - // rewrite. It starts over only at a load that holds nothing back, for every file that load read, - // whatever the load's outcome. Loads that hold another file back keep it, which bounds files + // rewrite. It starts over only at a load that holds nothing back, for every file that load got to, + // including one that fails to read or parse, whatever the load's outcome. Loads that hold another file back keep it, which bounds files // rewritten in turn or emptied in staggered turns, and a file that was read during a long failure // of another file has a fresh bound when that failure ends. A file whose content is not in effect, // such as a placeholder that has always been empty, or a file whose empty state was applied, is diff --git a/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/integrations/OverrideFileLoaderTest.java b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/integrations/OverrideFileLoaderTest.java index 6a725222..871b17b3 100644 --- a/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/integrations/OverrideFileLoaderTest.java +++ b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/integrations/OverrideFileLoaderTest.java @@ -936,10 +936,10 @@ public void boundOfAFileHeldBackDuringAFailureStartsOverOnceItIsReadAgain() thro } @Test - public void fileFoundMissingDuringAFailureGetsAFreshBound() throws Exception { - // a.yaml is held back, then deleted while b.json fails to load. A load that finds it missing and - // holds nothing back starts its bound over, so when it comes back empty much later, while b.json - // is fixed, it is held back again. + public void fileFoundMissingStartsANewDelayWhenItIsCreatedEmptyAgain() throws Exception { + // While b.json fails, a.yaml is emptied, deleted, and created empty again with the state it had + // when it was first found empty, as within one tick of a coarse clock. Being missing ended its + // empty state, so it is held back with a new delay. try (TempDir dir = TempDir.create()) { Path a = write(dir, "a.yaml", YAML_DOC); String other = "{\"flagValues\":{\"other\":\"value\"}}"; @@ -948,26 +948,148 @@ public void fileFoundMissingDuringAFailureGetsAFreshBound() throws Exception { OverrideFileLoader loader = loader(clock, a, b); loadAndAccept(loader); + InPlaceWrites.rewrite(b, "{bad", () -> { }); InPlaceWrites.truncate(a); - long emptiedAt = Files.getLastModifiedTime(a).toMillis(); - assertHeld(loader, a); + java.nio.file.attribute.FileTime emptiedAt = Files.getLastModifiedTime(a); + assertLoadFailsFor(loader, b); + clock.advance(0.5); Files.delete(a); - InPlaceWrites.rewrite(b, "{bad", () -> { }); assertLoadFailsFor(loader, b); - clock.advance(6); + clock.advance(0.67); + Files.write(a, new byte[0]); + Files.setLastModifiedTime(a, emptiedAt); + assertLoadFailsFor(loader, b); InPlaceWrites.rewrite(b, other, () -> { }); - // File system timestamps are coarse; the file must not look as it did when it was found empty. - do { - Thread.sleep(20); - Files.write(a, new byte[0]); - } while (Files.getLastModifiedTime(a).toMillis() == emptiedAt); assertHeld(loader, a); } } @Test - public void fileThatAFailingLoadDidNotReadKeepsItsBound() throws Exception { + public void fixOfAFileSavedInPlaceWithAnErrorIsHeldBack() throws Exception { + // An operator saves the file in place with a mistake, and saves the fix in place long after. The + // loads in between fail on the file, and the fix's own gap is held back. + for (String name : new String[] { "data.yaml", "data.json" }) { + try (TempDir dir = TempDir.create()) { + boolean yaml = name.endsWith(".yaml"); + Path file = write(dir, name, yaml ? YAML_DOC : JSON_DOC); + TestClock clock = new TestClock(); + OverrideFileLoader loader = loader(clock, file); + loadAndAccept(loader); + + InPlaceWrites.truncate(file); + assertHeld(loader, file); + InPlaceWrites.rewrite(file, yaml ? "flagValues: [\n" : "{\"flagValues\": ", () -> { }); + assertLoadFailsFor(loader, file); + for (int i = 0; i < 6; i++) { + clock.advance(1); + assertLoadFailsFor(loader, file); + } + + InPlaceWrites.truncate(file); + assertHeld(loader, file); + InPlaceWrites.rewrite(file, yaml ? "flagValues:\n flag: \"fixed\"\n" : "{\"flagValues\":{\"flag\":\"fixed\"}}", + () -> { }); + assertEquals(name, 1, loader.load(RETRY).getFlagCount()); + } + } + } + + @Test + public void fileThatFailedToParseStartsANewDelayWhenItIsEmptiedAgain() throws Exception { + // Content that fails to parse also ends the file's empty state, so an emptying after it starts a + // new delay, even when a coarse clock gives it the state of the earlier emptying. + try (TempDir dir = TempDir.create()) { + Path file = write(dir, "overrides.yaml", YAML_DOC); + TestClock clock = new TestClock(); + OverrideFileLoader loader = loader(clock, file); + loadAndAccept(loader); + + InPlaceWrites.truncate(file); + java.nio.file.attribute.FileTime emptiedAt = Files.getLastModifiedTime(file); + assertHeld(loader, file); + clock.advance(0.5); + InPlaceWrites.rewrite(file, "flagValues: [\n", () -> { }); + assertLoadFailsFor(loader, file); + clock.advance(0.7); + InPlaceWrites.truncate(file); + Files.setLastModifiedTime(file, emptiedAt); + assertHeld(loader, file); + } + } + + @Test + public void fileThatFailsToReadStartsItsBoundOver() throws Exception { + // A load that fails because a file cannot be read holds nothing back, so it starts that file's + // bound over too. + try (TempDir dir = TempDir.create()) { + Path file = write(dir, "overrides.yaml", YAML_DOC); + TestClock clock = new TestClock(); + AtomicLong unreadable = new AtomicLong(); + OverrideFileLoader loader = new OverrideFileLoader(Collections.singletonList(file), + FileData.DuplicateKeysHandling.FAIL, clock.nanos::get, path -> { + if (unreadable.get() != 0) { + throw new java.io.IOException("permission denied"); + } + return Files.readAllBytes(path); + }); + loadAndAccept(loader); + + InPlaceWrites.truncate(file); + assertHeld(loader, file); + unreadable.set(1); + for (int i = 0; i < 6; i++) { + clock.advance(1); + assertLoadFailsFor(loader, file); + } + unreadable.set(0); + writeAgain(file, " "); + assertHeld(loader, file); + } + } + + // Loads the files and applies the result, as the reloader does, or reports that the load held a + // file back or failed. + private static boolean loadAppliesOrFails(OverrideFileLoader loader) throws Exception { + try { + loader.accepted(loader.load(RETRY)); + return true; + } catch (EmptiedFileException | FileDataException e) { + return false; + } + } + + @Test + public void loadThatHoldsAFileBackAndThenFailsDoesNotStartBoundsOver() throws Exception { + // a.yaml and b.yaml are rewritten in turn, so that every load holds one of them back, and c.json + // fails at every other load. Those loads hold a file back, so they start no bound over. + try (TempDir dir = TempDir.create()) { + Path a = write(dir, "a.yaml", YAML_DOC); + String bDoc = "flagValues:\n other: \"value\"\n"; + Path b = write(dir, "b.yaml", bDoc); + String cGood = "{\"flagValues\":{\"third\":\"value\"}}"; + Path c = write(dir, "c.json", cGood); + TestClock clock = new TestClock(); + OverrideFileLoader loader = loader(clock, a, b, c); + loadAndAccept(loader); + + InPlaceWrites.truncate(a); + assertHeld(loader, a); + boolean applied = false; + for (int i = 1; i <= 12 && !applied; i++) { + clock.advance(0.5); + boolean odd = i % 2 == 1; + InPlaceWrites.rewrite(odd ? a : b, odd ? YAML_DOC : bDoc, () -> { }); + InPlaceWrites.truncate(odd ? b : a); + InPlaceWrites.rewrite(c, odd ? "{bad" : cGood, () -> { }); + applied = loadAppliesOrFails(loader); + } + assertTrue("the files were held back without end", applied); + } + } + + @Test + public void fileThatAFailingLoadDidNotReachKeepsItsBound() throws Exception { // c.json comes first and fails now and then, so those loads never read a.yaml, which keeps being // written while empty. They do not start a.yaml's bound over. try (TempDir dir = TempDir.create()) { From 0ceb5884e2dd623cfcd9c482c1b0685b394097a3 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Tue, 3 Nov 2026 07:32:18 +0000 Subject: [PATCH 29/30] fix: Read every override file before failing a load, and detect the text encoding A load stopped at the first file that failed to read or parse, and the entries were merged while the files were read, so a duplicate key also stopped it. A file configured after the failing one was never read, so its bound never started over, and its next in-place rewrite was applied empty. A load now reads and classifies every file, fails with the first failure after them, before a hold, and merges the entries only after every file is read. The skip-unchanged digest now covers each file's path and whether it exists: the format comes from the path, so the same bytes moved from a .yaml to a .json file are different data. Override JSON was decoded in the platform's default encoding, so UTF-16 files, which loaded as YAML before the format came from the extension, failed. Both formats now detect a byte order mark and read UTF-8 otherwise; the file data source keeps its behavior. A JSON document that is not an object, such as null, fails with a clear message instead of a NullPointerException. The docs say that every public override API is experimental, state the extension rule on both filePaths methods, and note that the YAML parser rejects some JSON. --- .../integrations/FileDataSourceParsing.java | 43 +++- .../FileOverrideSourceBuilder.java | 15 ++ .../server/integrations/FileOverrides.java | 10 +- .../integrations/OverrideFileLoader.java | 116 +++++++-- .../FileDataReloaderEmptiedFileTest.java | 34 ++- .../integrations/OverrideFileLoaderTest.java | 221 +++++++++++++++++- 6 files changed, 399 insertions(+), 40 deletions(-) diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FileDataSourceParsing.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FileDataSourceParsing.java index abf8e764..179661f5 100644 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FileDataSourceParsing.java +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FileDataSourceParsing.java @@ -2,6 +2,7 @@ import com.google.gson.Gson; import com.google.gson.JsonElement; +import com.google.gson.JsonIOException; import com.google.gson.JsonSyntaxException; import com.launchdarkly.sdk.LDValue; import com.launchdarkly.sdk.ObjectBuilder; @@ -11,6 +12,7 @@ import org.yaml.snakeyaml.Yaml; import org.yaml.snakeyaml.constructor.SafeConstructor; import org.yaml.snakeyaml.error.YAMLException; +import org.yaml.snakeyaml.reader.UnicodeReader; import org.yaml.snakeyaml.representer.Representer; import java.io.ByteArrayInputStream; @@ -116,6 +118,26 @@ static FlagFileParser selectForPath(Path path) { ? YamlParserHolder.getInstance() : JsonParserHolder.getInstance(); } + /** + * Parses the content of a file with the parser that {@link #selectForPath(Path)} chooses. The + * text encoding is detected from a byte order mark, UTF-8, UTF-16 or UTF-32, and is UTF-8 + * otherwise, for both formats; the YAML parser does that itself. The override source uses this. + * The file data source keeps reading JSON in the platform's default encoding. + * + * @param path the file + * @param data its content + * @return the parsed content + * @throws FileDataException if the content cannot be decoded or parsed + * @throws IOException if the content cannot be read + */ + static FlagFileRep parseForPath(Path path, byte[] data) throws FileDataException, IOException { + FlagFileParser parser = selectForPath(path); + if (parser instanceof JsonFlagFileParser) { + return ((JsonFlagFileParser) parser).parse(new UnicodeReader(new ByteArrayInputStream(data))); + } + return parser.parse(new ByteArrayInputStream(data)); + } + private static boolean detectJson(Reader r) { // A valid JSON file for our purposes must be an object, i.e. it must start with '{' while (true) { @@ -143,11 +165,26 @@ static final class JsonFlagFileParser extends FlagFileParser { @Override public FlagFileRep parse(InputStream input) throws FileDataException, IOException { + return parse(new InputStreamReader(input)); + } + + /** + * Parses JSON from text that is already decoded. + * + * @param reader the text + * @return the parsed content + * @throws FileDataException if the text is not a JSON object, or cannot be decoded + * @throws IOException if the text cannot be read + */ + FlagFileRep parse(Reader reader) throws FileDataException, IOException { JsonElement tree; try { - tree = gson.fromJson(new InputStreamReader(input), JsonElement.class); + tree = gson.fromJson(reader, JsonElement.class); } catch (JsonSyntaxException e) { throw new FileDataException("cannot parse JSON", e); + } catch (JsonIOException e) { + // For example, bytes that are not valid in the detected encoding. + throw new FileDataException("cannot read JSON", e); } if (tree == null) { // Gson finds no value in a file that is empty or white space only, or, since it reads @@ -157,6 +194,10 @@ public FlagFileRep parse(InputStream input) throws FileDataException, IOExceptio // when the file starts with '{'. throw new FileDataException("the file has no content, which is not a JSON document", null); } + if (!tree.isJsonObject()) { + // A document such as null or [] holds no flags. The file data source never gets here either. + throw new FileDataException("the JSON document is not an object", null); + } return parseJson(tree); } diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FileOverrideSourceBuilder.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FileOverrideSourceBuilder.java index 5f1a397e..ef2abf29 100644 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FileOverrideSourceBuilder.java +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FileOverrideSourceBuilder.java @@ -43,6 +43,8 @@ public final class FileOverrideSourceBuilder implements ComponentConfigurer * A file whose name ends in {@code .yaml} or {@code .yml}, in any case, is parsed as YAML, and any * other file is parsed as JSON. + *

+ * Flag overrides are currently experimental and subject to change. * * @param filePaths path(s) to the file(s); may be absolute or relative to the current working directory * @return the same builder @@ -59,6 +61,11 @@ public FileOverrideSourceBuilder filePaths(String... filePaths) throws InvalidPa * Adds any number of files to load overrides from, specifying each file path as a Path. The * order is significant: it determines which file wins under the duplicate keys handling when the * same key appears in more than one file. + *

+ * A file whose name ends in {@code .yaml} or {@code .yml}, in any case, is parsed as YAML, and any + * other file is parsed as JSON. + *

+ * Flag overrides are currently experimental and subject to change. * * @param filePaths path(s) to the file(s); may be absolute or relative to the current working directory * @return the same builder @@ -77,6 +84,8 @@ public FileOverrideSourceBuilder filePaths(Path... filePaths) { * segment key appears in more than one file, and the previously loaded overrides stay in effect. * With {@link FileData.DuplicateKeysHandling#IGNORE}, the entry from the first configured file that * defines the key is kept and the others are discarded. A null value selects the default. + *

+ * Flag overrides are currently experimental and subject to change. * * @param duplicateKeysHandling specifies how to handle duplicate keys * @return the same builder @@ -92,6 +101,8 @@ public FileOverrideSourceBuilder duplicateKeysHandling(FileData.DuplicateKeysHan * The two modes are alternatives, so setting one replaces the other. {@link ChangeDetection#WATCHING} * requires every file path to be on the default file system; a path of another file system provider, * such as a zip file system, can be polled but not watched, and {@link #build(ClientContext)} rejects it. + *

+ * Flag overrides are currently experimental and subject to change. * * @param changeDetection the change detection mode * @return the same builder @@ -106,6 +117,8 @@ public FileOverrideSourceBuilder changeDetection(ChangeDetection changeDetection * {@link ChangeDetection#WATCHING} mode ignores it. The default is * {@link FileOverrides#DEFAULT_POLL_INTERVAL}. An interval below * {@link FileOverrides#MINIMUM_POLL_INTERVAL} is raised to the minimum. + *

+ * Flag overrides are currently experimental and subject to change. * * @param pollInterval the polling interval * @return the same builder @@ -117,6 +130,8 @@ public FileOverrideSourceBuilder pollInterval(Duration pollInterval) { /** * Called internally by the SDK to create the override source. + *

+ * Flag overrides are currently experimental and subject to change. * * @param context the client context * @return the override source diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FileOverrides.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FileOverrides.java index 006b783d..9eec6884 100644 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FileOverrides.java +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FileOverrides.java @@ -45,12 +45,14 @@ public enum ChangeDetection { * the size of a file changes. Polling works on every file system, including network mounts and * directories whose contents are swapped through symbolic links, as Kubernetes does for mounted * ConfigMaps. It is the default. + * Flag overrides are currently experimental and subject to change. */ POLLING, /** * The source reloads in response to file system change notifications. It reacts faster than * polling. It depends on notifications, which some file systems do not deliver reliably. + * Flag overrides are currently experimental and subject to change. */ WATCHING } @@ -60,12 +62,14 @@ public enum ChangeDetection { * {@link ChangeDetection#POLLING} mode when no interval was specified. Because the source reads * local files rather than contacting a service, a short interval keeps an override responsive * during an incident at negligible cost. + * Flag overrides are currently experimental and subject to change. */ public static final Duration DEFAULT_POLL_INTERVAL = Duration.ofSeconds(1); /** * The shortest allowed polling interval. A configured interval below this is raised to it. The * minimum exists only to prevent a pathological tight loop over the file system. + * Flag overrides are currently experimental and subject to change. */ public static final Duration MINIMUM_POLL_INTERVAL = Duration.ofSeconds(1); @@ -79,7 +83,11 @@ private FileOverrides() {} * file is a JSON or YAML document with optional {@code "flags"}, {@code "flagValues"}, and * {@code "segments"} members. A file whose name ends in {@code .yaml} or {@code .yml}, in any case, * is parsed as YAML, and any other file is parsed as JSON. This differs from the file data source, - * which parses a file as JSON when it starts with '{' and as YAML otherwise. A + * which parses a file as JSON when it starts with '{' and as YAML otherwise. The YAML + * parser reads YAML 1.1, which does not accept every JSON document, for example one indented with + * tabs or one with the escape \/, so give a JSON file a name that does not end in + * {@code .yaml} or {@code .yml}. The text encoding of a file is detected from a byte order mark, + * and is UTF-8 otherwise. A * {@code "flagValues"} entry is expanded into a full flag * definition that is on and serves the given value as its single variation by fallthrough for * every context. Such a flag is not available to client-side SDKs, so diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/OverrideFileLoader.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/OverrideFileLoader.java index 43cce040..f58488c5 100644 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/OverrideFileLoader.java +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/OverrideFileLoader.java @@ -11,8 +11,11 @@ import com.launchdarkly.sdk.server.subsystems.DataStoreTypes.ItemDescriptor; import com.launchdarkly.sdk.server.subsystems.DataStoreTypes.KeyedItems; +import org.yaml.snakeyaml.reader.UnicodeReader; + import java.io.ByteArrayInputStream; import java.io.IOException; +import java.io.Reader; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.NoSuchFileException; @@ -102,7 +105,8 @@ boolean isPresent() { } /** - * False when the file does not exist, or is empty or white space only. + * False when the file does not exist, is empty, holds only white space or a byte order mark, or + * holds content that its parser reads as no document, such as YAML with only a comment. */ boolean hasContent() { return hasContent; @@ -247,12 +251,16 @@ private static final class EmptyFile { @Override public LoadResult load(Duration retryDelay) throws FileDataException, EmptiedFileException { MessageDigest digest = newDigest(); - Map> data = new HashMap<>(); - List files = new ArrayList<>(paths.size()); List heldBack = new ArrayList<>(); // The files that this load got to and did not hold back: read with content, parsed empty, found - // missing, or failing to read or parse. + // missing, or failing to read or parse. A load reads every configured file, also after a failure, + // so every file that is not held back is here. List reached = new ArrayList<>(); + // The parsed documents of the files that are not held back, in configuration order. + List documents = new ArrayList<>(paths.size()); + // The first failure to read or parse a file. The other files are still read, so that each file + // is classified, and the load fails with this one after them. + FileDataException failure = null; try { for (Path path : paths) { byte[] raw; @@ -261,11 +269,13 @@ public LoadResult load(Duration retryDelay) throws FileDataException, EmptiedFil } catch (NoSuchFileException e) { // Absence is a valid state, not a failure. noteNotEmpty(path, reached); - files.add(new FileSummary(path, false, false, 0, 0)); + updateDigest(digest, path, null); + documents.add(new ReadFile(path, null, false)); continue; } catch (IOException e) { reached.add(path); - throw new FileDataException("file " + path + ": unable to read file", e); + failure = first(failure, new FileDataException("file " + path + ": unable to read file", e)); + continue; } // Content that the parser reads as no document, such as YAML with only a comment, has no // entries, and a read can see that much of a file that is being written. Content with only @@ -277,7 +287,8 @@ public LoadResult load(Duration retryDelay) throws FileDataException, EmptiedFil document = parse(path, raw); } catch (FileDataException e) { noteNotEmpty(path, reached); - throw e; + failure = first(failure, e); + continue; } hasContent = document != FlagFileRep.NO_DOCUMENT; } @@ -294,21 +305,38 @@ public LoadResult load(Duration retryDelay) throws FileDataException, EmptiedFil } // One read feeds both the digest and the parse, so the skip-unchanged digest can never disagree // with the content that was applied. - if (digest != null) { - digest.update(raw); - digest.update((byte) 0); - } + updateDigest(digest, path, raw); if (document == null) { - document = parse(path, raw); + try { + document = parse(path, raw); + } catch (FileDataException e) { + failure = first(failure, e); + continue; + } } - int[] added = addEntries(data, path, document); - files.add(new FileSummary(path, true, hasContent, added[0], added[1])); + documents.add(new ReadFile(path, document, hasContent)); } + // A failure fails the load before a hold, and a hold comes before the merge. + if (failure != null) { + throw failure; + } if (!heldBack.isEmpty()) { throw new EmptiedFileException(heldBack, holdBack(heldBack, retryDelay, nanoClock.getAsLong())); } + // The entries are merged only after every file is read, so that a duplicate key or an entry + // that the data model rejects cannot keep a later file from being read. + Map> data = new HashMap<>(); + List files = new ArrayList<>(documents.size()); + for (ReadFile file : documents) { + if (file.document == null) { + files.add(new FileSummary(file.path, false, false, 0, 0)); + continue; + } + int[] added = addEntries(data, file.path, file.document); + files.add(new FileSummary(file.path, true, file.hasContent, added[0], added[1])); + } ImmutableList.Builder>> all = ImmutableList.builder(); for (Map.Entry> e : data.entrySet()) { all.add(new AbstractMap.SimpleEntry<>(e.getKey(), new KeyedItems<>(ImmutableList.copyOf(e.getValue().entrySet())))); @@ -320,9 +348,8 @@ public LoadResult load(Duration retryDelay) throws FileDataException, EmptiedFil } finally { if (heldBack.isEmpty()) { // A load that holds nothing back, whether it is applied, skipped as unchanged, or fails, - // starts the bound over for every file that it got to, including the one that failed: such a - // file is no longer held, so its next rewrite is a new one. A file after a failing one, which - // the load did not get to, keeps its bound. + // starts the bound over for every file that it got to, including one that failed: such a + // file is no longer held, so its next rewrite is a new one. for (Path path : reached) { EmptyFile empty = emptyFiles.get(path); if (empty != null) { @@ -333,6 +360,41 @@ public LoadResult load(Duration retryDelay) throws FileDataException, EmptiedFil } } + /** + * A file that a load read and did not hold back. + */ + private static final class ReadFile { + final Path path; + // The parsed content, or null if the file does not exist. + final FlagFileRep document; + final boolean hasContent; + + ReadFile(Path path, FlagFileRep document, boolean hasContent) { + this.path = path; + this.document = document; + this.hasContent = hasContent; + } + } + + private static FileDataException first(FileDataException earlier, FileDataException e) { + return earlier != null ? earlier : e; + } + + // Adds a file to the skip-unchanged digest: its path, whether it exists, and its content. The format + // depends on the path, so the same bytes at another path can be different data. + private static void updateDigest(MessageDigest digest, Path path, byte[] raw) { + if (digest == null) { + return; + } + digest.update(path.toString().getBytes(StandardCharsets.UTF_8)); + digest.update((byte) 0); + digest.update((byte) (raw == null ? 0 : 1)); + if (raw != null) { + digest.update(raw); + digest.update((byte) 0); + } + } + // For a file that a load read with content, including content that fails to parse, or found // missing: if it was empty before, its empty state has ended, so its next emptying starts a new delay // even where a coarse modification time makes it look the same. @@ -346,7 +408,7 @@ private void noteNotEmpty(Path path, List reached) { private static FlagFileRep parse(Path path, byte[] raw) throws FileDataException { try { - return FlagFileParser.selectForPath(path).parse(new ByteArrayInputStream(raw)); + return FlagFileParser.parseForPath(path, raw); } catch (FileDataException e) { throw new FileDataException("file " + path + ": " + e.getMessage(), e.getCause()); } catch (IOException e) { @@ -466,16 +528,20 @@ private long holdBack(List heldBack, Duration retryDelay, long now) { return Math.max(remaining, 0) + delay / 10; } - // A file with only white space, or a byte order mark, has no content. + // A file with only white space, after a byte order mark if it has one, has no content. The text is + // decoded as the parsers decode it. Content that cannot be decoded is left to the parser to report. private static boolean hasContent(byte[] raw) { - String text = new String(raw, StandardCharsets.UTF_8); - for (int i = 0; i < text.length(); i++) { - char c = text.charAt(i); - if (!Character.isWhitespace(c) && c != '\uFEFF') { - return true; + try (Reader text = new UnicodeReader(new ByteArrayInputStream(raw))) { + int c; + while ((c = text.read()) >= 0) { + if (!Character.isWhitespace(c)) { + return true; + } } + return false; + } catch (IOException e) { + return true; } - return false; } // Adds an entry unless the key was already added. Reports whether it added the entry. diff --git a/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/integrations/FileDataReloaderEmptiedFileTest.java b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/integrations/FileDataReloaderEmptiedFileTest.java index 8f21e088..561d8b40 100644 --- a/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/integrations/FileDataReloaderEmptiedFileTest.java +++ b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/integrations/FileDataReloaderEmptiedFileTest.java @@ -76,7 +76,8 @@ public void apply(LoadResult result) { } for (Map.Entry item : kind.getValue().getItems()) { if (item.getKey().equals("flag")) { - value = LDValue.parse(FEATURES.serialize(item.getValue())).get("variations").get(0).stringValue(); + LDValue v = LDValue.parse(FEATURES.serialize(item.getValue())).get("variations").get(0); + value = v.isString() ? v.stringValue() : v.toJsonString(); } } } @@ -331,6 +332,22 @@ public void fileRewrittenWithTheSameContentIsHeldBackAgainLater() throws Excepti } } + @Test + public void sameBytesMovedToAFileOfAnotherFormatAreApplied() throws Exception { + // The format depends on the path, so the load after the move is not skipped as unchanged: YAML + // reads on as true, and JSON reads it as a string. + try (TempDir dir = TempDir.create()) { + Path yaml = write(dir, "overrides.yaml", "{\"flagValues\": {\"flag\": on}}"); + Path json = dir.getPath().resolve("overrides.json"); + Recorder recorder = new Recorder(); + FileDataReloader r = reloader(new CountingLoader(yaml, json), recorder, HOUR); + r.reloadNow(); + Files.move(yaml, json); + r.reloadNow(); + assertEquals(Arrays.asList("true", "on"), recorder.applied); + } + } + @Test public void changesToOtherFilesWaitWhileAFileIsHeld() throws Exception { // A reload applies all the files or none. @@ -546,9 +563,10 @@ public void filesEmptiedTogetherAreAppliedByTheFirstRetry() throws Exception { @Test public void fileMovedToAnotherConfiguredPathIsDeferredWhenItIsRewritten() throws Exception { - // Moving a file from one configured path to another leaves the same content in effect, so the - // load is skipped as unchanged. It must still record that the content now comes from the new - // path, or an in-place rewrite of that path would be applied while it is empty. + // Moving a file from one configured path to another leaves the same flags in effect. The load + // after the move records that the content now comes from the new path, or an in-place rewrite + // of that path would be applied while it is empty. The skip-unchanged digest covers the paths, + // so that load is applied rather than skipped. try (TempDir dir = TempDir.create()) { Path a = write(dir, "a.yaml", yaml("a")); Path b = dir.getPath().resolve("b.yaml"); @@ -559,17 +577,17 @@ public void fileMovedToAnotherConfiguredPathIsDeferredWhenItIsRewritten() throws Files.move(a, b); r.trigger(); - loader.awaitFinished(2); - assertEquals(Collections.singletonList("a"), recorder.applied); + awaitSize(recorder.applied, 2); + assertEquals(Arrays.asList("a", "a"), recorder.applied); InPlaceWrites.rewrite(b, yaml("b"), () -> { r.trigger(); loader.awaitFinished(3); }); r.trigger(); - awaitSize(recorder.applied, 2); + awaitSize(recorder.applied, 3); - assertEquals(Arrays.asList("a", "b"), recorder.applied); + assertEquals(Arrays.asList("a", "a", "b"), recorder.applied); assertThat(logCapture.getMessageStrings(), hasItem(readAgainLog(b))); } } diff --git a/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/integrations/OverrideFileLoaderTest.java b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/integrations/OverrideFileLoaderTest.java index 871b17b3..cdd8665f 100644 --- a/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/integrations/OverrideFileLoaderTest.java +++ b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/integrations/OverrideFileLoaderTest.java @@ -13,6 +13,7 @@ import org.junit.Test; +import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; @@ -368,8 +369,18 @@ public void formatIsChosenByTheFileNameExtension() throws Exception { for (String name : new String[] { "a.yaml", "a.yml", "a.YAML", "a.Yml" }) { assertEquals(name, 1, loader(write(dir, name, YAML_DOC)).load(Duration.ZERO).getFlagCount()); } - // JSON is valid YAML, so a YAML file can hold a JSON document. + // A YAML file can hold most JSON documents. The YAML parser reads YAML 1.1, which rejects some + // JSON, such as indentation with tabs or the escape \/. assertEquals(1, loader(write(dir, "json.yaml", JSON_DOC)).load(Duration.ZERO).getFlagCount()); + for (String json : new String[] { "{\n\t\"flagValues\": {\"flag\": \"v\"}\n}", "{\"flagValues\":{\"flag\":\"a\\/b\"}}" }) { + Path file = write(dir, "strict.yaml", json); + try { + loader(file).load(Duration.ZERO); + fail("expected the YAML parser to reject " + json); + } catch (FileDataException e) { + assertThat(e.getMessage(), containsString(file + ": unable to parse YAML")); + } + } // Any other file is JSON, whatever its content. for (String name : new String[] { "b.json", "b.txt", "b", "b.yaml.bak" }) { Path file = write(dir, name, YAML_DOC); @@ -801,7 +812,7 @@ public void applyingDataStartsTheBoundOver() throws Exception { } @Test - public void fileThatStayedEmptyForTheDelayGetsAFreshBound() throws Exception { + public void jsonFileThatStaysEmptyIsHeldBackAgainWhenRewrittenLater() throws Exception { // A JSON file that stays empty is parsed after the delay and fails. That load holds nothing back, // so the file's bound starts over, and when it is later rewritten in place, the new truncation is // held back again instead of reported. @@ -1048,6 +1059,206 @@ public void fileThatFailsToReadStartsItsBoundOver() throws Exception { } } + @Test + public void boundOfAFileAfterAFailingFileStartsOverOnceItIsReadAgain() throws Exception { + // c.json comes first. While it fails, a.yaml, which was held back, gets its content; the failing + // loads still read a.yaml, so its bound starts over, and the in-place rewrite of a.yaml that comes + // with the fix of c.json is held back. + try (TempDir dir = TempDir.create()) { + String other = "{\"flagValues\":{\"other\":\"value\"}}"; + Path c = write(dir, "c.json", other); + Path a = write(dir, "a.yaml", YAML_DOC); + TestClock clock = new TestClock(); + OverrideFileLoader loader = loader(clock, c, a); + loadAndAccept(loader); + + InPlaceWrites.truncate(a); + assertHeld(loader, a); + InPlaceWrites.rewrite(c, "{bad", () -> { }); + InPlaceWrites.rewrite(a, YAML_DOC, () -> { }); + for (int i = 0; i < 6; i++) { + assertLoadFailsFor(loader, c); + clock.advance(1); + } + InPlaceWrites.rewrite(c, other, () -> { }); + InPlaceWrites.truncate(a); + assertHeld(loader, a); + } + } + + @Test + public void failingLoadReportsTheFirstFailure() throws Exception { + try (TempDir dir = TempDir.create()) { + Path first = write(dir, "first.json", "{bad"); + Path second = write(dir, "second.yaml", "flagValues: [\n"); + try { + loader(first, second).load(RETRY); + fail("expected exception"); + } catch (FileDataException e) { + assertThat(e.getMessage(), containsString(first + ": cannot parse JSON")); + } + } + } + + @Test + public void fileAfterADuplicateKeyIsReadSoItsBoundStartsOver() throws Exception { + // The entries are merged only after every file is read, so a duplicate key between a.yaml and + // b.yaml does not keep the loads from reading c.yaml, whose bound starts over while they fail. + try (TempDir dir = TempDir.create()) { + Path a = write(dir, "a.yaml", "flagValues:\n k: \"a\"\n"); + String bDoc = "flagValues:\n j: \"b\"\n"; + Path b = write(dir, "b.yaml", bDoc); + Path c = write(dir, "c.yaml", YAML_DOC); + TestClock clock = new TestClock(); + OverrideFileLoader loader = loader(clock, a, b, c); + loadAndAccept(loader); + + InPlaceWrites.truncate(c); + assertHeld(loader, c); + InPlaceWrites.rewrite(b, "flagValues:\n j: \"b\"\n k: \"dup\"\n", () -> { }); + InPlaceWrites.rewrite(c, YAML_DOC, () -> { }); + for (int i = 0; i < 8; i++) { + clock.advance(1); + try { + loader.load(RETRY); + fail("expected exception"); + } catch (FileDataException e) { + assertThat(e.getMessage(), containsString("key \"k\" was already defined")); + } + } + InPlaceWrites.rewrite(b, bDoc, () -> { }); + truncateAgain(c); + assertHeld(loader, c); + } + } + + @Test + public void fixOfAFileSavedInPlaceWithADuplicateKeyIsHeldBack() throws Exception { + // As with a parse error: b.yaml is saved in place with a key that a.yaml defines, then the fix is + // saved in place several retry delays later. The fix's gap is held back. + try (TempDir dir = TempDir.create()) { + Path a = write(dir, "a.yaml", "flagValues:\n k: \"a\"\n"); + Path b = write(dir, "b.yaml", "flagValues:\n j: \"b\"\n"); + TestClock clock = new TestClock(); + OverrideFileLoader loader = loader(clock, a, b); + loadAndAccept(loader); + + InPlaceWrites.truncate(b); + assertHeld(loader, b); + InPlaceWrites.rewrite(b, "flagValues:\n j: \"b\"\n k: \"dup\"\n", () -> { }); + for (int i = 0; i < 7; i++) { + try { + loader.load(RETRY); + fail("expected exception"); + } catch (FileDataException e) { + assertThat(e.getMessage(), containsString("key \"k\" was already defined")); + } + clock.advance(1); + } + InPlaceWrites.truncate(b); + assertHeld(loader, b); + } + } + + @Test + public void slowReadOfTheEmptiedFileItselfDoesNotShortenItsDelay() throws Exception { + // The emptied file's own read stalls for most of a retry delay, as on a network file system. Its + // delay counts from after its read. + try (TempDir dir = TempDir.create()) { + Path a = write(dir, "a.yaml", YAML_DOC); + TestClock clock = new TestClock(); + AtomicLong slowRead = new AtomicLong(); + OverrideFileLoader loader = new OverrideFileLoader(Collections.singletonList(a), + FileData.DuplicateKeysHandling.FAIL, clock.nanos::get, path -> { + byte[] raw = Files.readAllBytes(path); + if (slowRead.getAndSet(0) != 0) { + clock.advance(0.9); + } + return raw; + }); + loadAndAccept(loader); + + InPlaceWrites.truncate(a); + slowRead.set(1); + assertHeld(loader, a); + clock.advance(0.2); + assertHeld(loader, a); + } + } + + @Test + public void jsonDocumentThatIsNotAnObjectFailsToParse() throws Exception { + try (TempDir dir = TempDir.create()) { + for (String doc : new String[] { "null", "[]", "5", "\"x\"" }) { + Path file = write(dir, "overrides.json", doc); + try { + loader(file).load(Duration.ZERO); + fail("expected exception for " + doc); + } catch (FileDataException e) { + assertEquals("file " + file + ": the JSON document is not an object", e.getMessage()); + assertNull(e.getCause()); + } + } + } + } + + @Test + public void textEncodingIsDetectedFromAByteOrderMarkAndIsUtf8Otherwise() throws Exception { + // Applies to both formats. Without a byte order mark the text is UTF-8, whatever the platform's + // default encoding. + String[] names = { "overrides.json", "overrides.yaml" }; + java.nio.charset.Charset[] charsets = { StandardCharsets.UTF_8, StandardCharsets.UTF_16LE, + StandardCharsets.UTF_16BE }; + try (TempDir dir = TempDir.create()) { + for (String name : names) { + String doc = name.endsWith(".json") ? "{\"flagValues\":{\"flag\":\"caf\u00e9\"}}" + : "flagValues:\n flag: \"caf\u00e9\"\n"; + for (java.nio.charset.Charset charset : charsets) { + for (boolean bom : new boolean[] { true, false }) { + if (!bom && charset != StandardCharsets.UTF_8) { + continue; + } + Path file = dir.getPath().resolve(name); + Files.write(file, ((bom ? "\uFEFF" : "") + doc).getBytes(charset)); + LoadResult result = loader(file).load(Duration.ZERO); + String what = name + " " + charset + (bom ? " with a byte order mark" : ""); + assertEquals(what, LDValue.buildArray().add("caf\u00e9").build(), + json(FEATURES, toMap(result).get(FEATURES).get("flag")).get("variations")); + } + } + } + } + } + + @Test + public void utf16FileWithOnlyWhiteSpaceCountsAsEmpty() throws Exception { + try (TempDir dir = TempDir.create()) { + Path file = write(dir, "overrides.yaml", YAML_DOC); + OverrideFileLoader loader = loader(new TestClock(), file); + loadAndAccept(loader); + + Files.write(file, "\uFEFF \n".getBytes(StandardCharsets.UTF_16LE)); + assertHeld(loader, file); + } + } + + @Test + public void contentHashCoversPathsAndPresence() throws Exception { + // The format depends on the path, so the same bytes moved from one configured file to another + // can be different data. + try (TempDir dir = TempDir.create()) { + Path yaml = write(dir, "overrides.yaml", "{\"flagValues\": {\"flag\": on}}"); + Path json = dir.getPath().resolve("overrides.json"); + OverrideFileLoader loader = loader(yaml, json); + LoadResult before = loader.load(Duration.ZERO); + Files.move(yaml, json); + LoadResult after = loader.load(Duration.ZERO); + assertThat(Arrays.equals(before.getContentHash(), after.getContentHash()), is(false)); + assertEquals(LDValue.of(true), json(FEATURES, toMap(before).get(FEATURES).get("flag")).get("variations").get(0)); + assertEquals(LDValue.of("on"), json(FEATURES, toMap(after).get(FEATURES).get("flag")).get("variations").get(0)); + } + } + // Loads the files and applies the result, as the reloader does, or reports that the load held a // file back or failed. private static boolean loadAppliesOrFails(OverrideFileLoader loader) throws Exception { @@ -1089,9 +1300,9 @@ public void loadThatHoldsAFileBackAndThenFailsDoesNotStartBoundsOver() throws Ex } @Test - public void fileThatAFailingLoadDidNotReachKeepsItsBound() throws Exception { - // c.json comes first and fails now and then, so those loads never read a.yaml, which keeps being - // written while empty. They do not start a.yaml's bound over. + public void fileKeptWrittenWhileEmptyIsAppliedAfterItsBoundWhileAnEarlierFileFails() throws Exception { + // c.json comes first and fails now and then, while a.yaml keeps being written while empty. The + // failing loads still read a.yaml and hold it back, so they do not start its bound over. try (TempDir dir = TempDir.create()) { String other = "{\"flagValues\":{\"other\":\"value\"}}"; Path c = write(dir, "c.json", other); From 0c37016e97de6120168b9392e68772178d78e4df Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Tue, 3 Nov 2026 23:36:21 +0000 Subject: [PATCH 30/30] docs: Describe the file-based override source at the level an application needs The public docs described how long an emptied file is held back, the bound on it, and the other files waiting meanwhile. They now say what an application needs: which extension means YAML, how a file is decoded, that files can be rewritten in place, how to remove a file's overrides, that an empty JSON file is not a valid document, and that replacing a YAML file in one operation avoids briefly applying a partly written file. The mechanics stay in the internal comments. --- .../FileOverrideSourceBuilder.java | 6 ++- .../server/integrations/FileOverrides.java | 39 +++++++------------ 2 files changed, 18 insertions(+), 27 deletions(-) diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FileOverrideSourceBuilder.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FileOverrideSourceBuilder.java index ef2abf29..4d056392 100644 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FileOverrideSourceBuilder.java +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FileOverrideSourceBuilder.java @@ -42,7 +42,8 @@ public final class FileOverrideSourceBuilder implements ComponentConfigurer * A file whose name ends in {@code .yaml} or {@code .yml}, in any case, is parsed as YAML, and any - * other file is parsed as JSON. + * other file is parsed as JSON. A file is read as UTF-8, or in the encoding that a byte order mark + * names. *

* Flag overrides are currently experimental and subject to change. * @@ -63,7 +64,8 @@ public FileOverrideSourceBuilder filePaths(String... filePaths) throws InvalidPa * same key appears in more than one file. *

* A file whose name ends in {@code .yaml} or {@code .yml}, in any case, is parsed as YAML, and any - * other file is parsed as JSON. + * other file is parsed as JSON. A file is read as UTF-8, or in the encoding that a byte order mark + * names. *

* Flag overrides are currently experimental and subject to change. * diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FileOverrides.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FileOverrides.java index 9eec6884..02499f49 100644 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FileOverrides.java +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FileOverrides.java @@ -82,15 +82,11 @@ private FileOverrides() {} * The files use the same document format as the file data source (see {@link FileData}): each * file is a JSON or YAML document with optional {@code "flags"}, {@code "flagValues"}, and * {@code "segments"} members. A file whose name ends in {@code .yaml} or {@code .yml}, in any case, - * is parsed as YAML, and any other file is parsed as JSON. This differs from the file data source, - * which parses a file as JSON when it starts with '{' and as YAML otherwise. The YAML - * parser reads YAML 1.1, which does not accept every JSON document, for example one indented with - * tabs or one with the escape \/, so give a JSON file a name that does not end in - * {@code .yaml} or {@code .yml}. The text encoding of a file is detected from a byte order mark, - * and is UTF-8 otherwise. A - * {@code "flagValues"} entry is expanded into a full flag - * definition that is on and serves the given value as its single variation by fallthrough for - * every context. Such a flag is not available to client-side SDKs, so + * is YAML, and any other file is JSON; the YAML parser does not accept every JSON document, so give + * a JSON file a name that does not end in {@code .yaml} or {@code .yml}. A file is read as UTF-8, + * or in the encoding that a byte order mark names. A {@code "flagValues"} entry is expanded into a + * full flag definition that is on and serves the given value as its single variation by fallthrough + * for every context. Such a flag is not available to client-side SDKs, so * {@link com.launchdarkly.sdk.server.LDClient#allFlagsState} with * {@link com.launchdarkly.sdk.server.FlagsStateOption#CLIENT_SIDE_ONLY} leaves it out, even when * the flag it overrides is available to them. To override such a flag, define it in @@ -99,23 +95,16 @@ private FileOverrides() {} * the duplicate keys handling decides which file wins for a key that appears more than once. *

* A reload replaces the entire override set, so removing an entry from the files removes the - * override. A configured file that does not exist contributes no overrides. Deleting a file - * therefore removes its overrides, and deleting every file removes them all. A file that exists - * but cannot be read or parsed makes that whole reload fail: the previously loaded overrides - * stay in effect, the source logs the failure, retries after a short delay, and recovers on its - * own once the file is readable again. + * 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. *

- * A file can be rewritten in place. To remove a file's overrides, delete the file or write a - * document with no entries, such as {}. A JSON file with no content is not a valid - * document: it fails to load, and the overrides in effect stay. A file whose content is in effect - * and that is found empty, or with YAML content that holds no document, such as only a comment, may - * be in the middle of a rewrite. The source then keeps the overrides in effect until the file has - * stayed so, without being written, for about a second, or for at most about five seconds while the - * file keeps being rewritten. Changes to the other files wait too, because a reload applies all the - * files or none. After that, an empty YAML file removes its overrides, while an empty JSON file fails to - * load and the overrides in effect stay. YAML that is cut off between two entries can still parse, - * so for a YAML file, replacing its entire contents in one operation, such as renaming a fully - * written temporary file over it, avoids briefly applying only a part of it. + * 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, + * delete the file or write a document with no entries, such as {}. An empty JSON file + * is not a valid document. For a YAML file, replacing its contents in one operation, such as by + * renaming a fully written temporary file over it, avoids briefly applying a partly written file. *

* Whenever the set of overrides in effect changes, including at startup, the source logs the * overrides in effect and what each configured file supplied, at Info level.