Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
ca22bc0
feat: Add the override marker to the flag and segment models and mark…
kinyoklion Sep 25, 2026
8fbcf92
fix: Keep the override marking when a migration flag serves an unreco…
kinyoklion Oct 28, 2026
64740d7
fix: Mark the EXCEPTION result of an overridden flag
kinyoklion Oct 29, 2026
222bea8
feat: Add the override layer, the store overlay, and the override sou…
kinyoklion Sep 25, 2026
b955b36
test: Model value-only overrides as flags served by fallthrough
kinyoklion Oct 1, 2026
9fcdd5f
docs: Mark the constructor that takes an override source as experimental
kinyoklion Oct 3, 2026
825be9d
fix: Log a summary of the exception when the override source fails to…
kinyoklion Oct 3, 2026
b177a7b
fix: Notify overridden flags when the LaunchDarkly data they depend o…
kinyoklion Oct 16, 2026
ff37ce3
fix: Build the overrides-only flag state from the layer and keep a fa…
kinyoklion Oct 28, 2026
c680ae7
docs: State that an override source's start is not intended to fail
kinyoklion Oct 28, 2026
77ef6f4
fix: Keep the override flags in the all flags state when the store fails
kinyoklion Oct 29, 2026
e95c7a9
fix: Mark the EXCEPTION result of an overridden flag in the all flags…
kinyoklion Oct 29, 2026
e68252b
feat: Suppress individual events and split summary counters for overr…
kinyoklion Sep 25, 2026
0a91418
fix: Mark the error event of an overridden flag whose evaluation throws
kinyoklion Oct 29, 2026
90bc603
feat: Add the file-based override source
kinyoklion Sep 25, 2026
056d3f2
fix: Describe and expect the fallthrough expansion of value-only over…
kinyoklion Oct 1, 2026
b193881
fix: Pass the logger to the file data poller
kinyoklion Oct 3, 2026
79d61a9
fix: Log a summary of the exception when override files cannot be wat…
kinyoklion Oct 3, 2026
b1f7181
fix: Drop a file path listed more than once instead of treating its e…
kinyoklion Oct 15, 2026
8cdcabc
fix: Reject watching a file path that is not on the default file system
kinyoklion Oct 28, 2026
051fd14
fix: Poll the override files when they cannot be watched
kinyoklion Oct 28, 2026
2ab2fe7
docs: Note that a flagValues override is not available to client-side…
kinyoklion Oct 28, 2026
d9ec2af
feat: Add the flag-overrides capability to the contract test service
kinyoklion Sep 25, 2026
0316791
fix: Keep the overrides in effect while an override file is rewritten…
kinyoklion Nov 2, 2026
aa1b3f5
fix: Bound the hold of emptied override files by the run of holding l…
kinyoklion Nov 3, 2026
82902ec
fix: Bound each held-back override file on its own and read the clock…
kinyoklion Nov 3, 2026
cec4530
fix: Start a held-back override file's bound over only at a load that…
kinyoklion Nov 3, 2026
6c7909f
fix: Start the bound over for an override file whose read or parse fails
kinyoklion Nov 3, 2026
0ceb588
fix: Read every override file before failing a load, and detect the t…
kinyoklion Nov 3, 2026
0c37016
docs: Describe the file-based override source at the level an applica…
kinyoklion Nov 3, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions lib/sdk/server/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -70,8 +70,8 @@ ext.versions = [
"gson": "2.13.1",
"guava": "32.0.1-jre",
"jackson": "2.11.2",
"launchdarklyJavaSdkCommon": "2.3.0",
"launchdarklyJavaSdkInternal": "1.11.1",
"launchdarklyJavaSdkCommon": "2.6.0",
"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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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<Synchronizer> createSynchronizer(
SdkConfigSynchronizerParams synchronizer,
String payloadFilter) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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();
Expand Down Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,15 @@ static final class FeatureFlag implements VersionedData, JsonHelpers.PostProcess
private Migration migration;
private boolean excludeFromSummaries;

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

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

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

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

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

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

Segment() {}

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

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

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

public void afterDeserialized() {
DataModelPreprocessing.preprocessSegment(this);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<KindAndKey> itemsOut, KindAndKey item);
}

static final class DependencyTracker {
private final Map<KindAndKey, Set<KindAndKey>> dependenciesFrom = new HashMap<>();
private final Map<KindAndKey, Set<KindAndKey>> dependenciesTo = new HashMap<>();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -56,6 +59,10 @@ final class DataSourceUpdatesImpl implements DataSourceUpdateSink, DataSourceUpd
private final EventBroadcasterImpl<FlagChangeListener, FlagChangeEvent> flagChangeEventNotifier;
private final EventBroadcasterImpl<StatusListener, Status> 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();
Expand Down Expand Up @@ -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<KindAndKey> itemsOut, KindAndKey item) {
DataModelDependencies.ExternalDependencies external = externalDependencies;
if (external == null) {
dependencyTracker.addAffectedItems(itemsOut, item);
return;
}
Deque<KindAndKey> 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<KindAndKey> viaData = new HashSet<>();
dependencyTracker.addAffectedItems(viaData, next);
Set<KindAndKey> viaExternal = new HashSet<>();
external.addAffectedItems(viaExternal, next);
for (Set<KindAndKey> reached : Arrays.asList(viaData, viaExternal)) {
for (KindAndKey reachedItem : reached) {
if (itemsOut.add(reachedItem)) {
pending.push(reachedItem);
}
}
}
}
}

@Override
public boolean init(FullDataSet<ItemDescriptor> allData) {
Map<DataKind, Map<String, ItemDescriptor>> oldData = null;
Expand Down Expand Up @@ -132,7 +180,7 @@ public boolean upsert(DataKind kind, String key, ItemDescriptor item) {
dependencyTracker.updateDependenciesFrom(kind, key, item);
if (hasFlagChangeEventListeners()) {
Set<KindAndKey> affectedItems = new HashSet<>();
dependencyTracker.addAffectedItems(affectedItems, new KindAndKey(kind, key));
addAffectedItems(affectedItems, new KindAndKey(kind, key));
sendChangeEvents(affectedItems);
}
}
Expand Down Expand Up @@ -270,7 +318,7 @@ private Set<KindAndKey> computeChangedItemsForFullDataSet(Map<DataKind, Map<Stri
continue;
}
if (oldItem == null || newItem == null || oldItem.getVersion() < newItem.getVersion()) {
dependencyTracker.addAffectedItems(affectedItems, new KindAndKey(kind, key));
addAffectedItems(affectedItems, new KindAndKey(kind, key));
}
// Note that comparing the version numbers is sufficient; we don't have to compare every detail of the
// flag or segment configuration, because it's a basic underlying assumption of the entire LD data model
Expand Down Expand Up @@ -535,7 +583,7 @@ private Set<KindAndKey> handlePartialChangeset(
for (Map.Entry<String, ItemDescriptor> 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));
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -48,7 +57,8 @@ public void recordEvaluationEvent(LDContext context, String flagKey, int flagVer
debugEventsUntilDate,
false,
samplingRatio != null ? samplingRatio : 1,
excludeFromSummaries
excludeFromSummaries,
overrideAffected
));
}

Expand Down
Loading
Loading