Skip to content

Commit 51b497a

Browse files
committed
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<OverrideSource>), 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.
1 parent d82beda commit 51b497a

22 files changed

Lines changed: 2178 additions & 17 deletions

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

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,15 @@ interface DataSystem {
6262
* @return the environment ID, or null
6363
*/
6464
String getEnvironmentId();
65+
66+
/**
67+
* Returns the override layer, or null if no override source is configured. The layer holds the
68+
* entries that take precedence over LaunchDarkly data; {@link #getStore()} already applies them.
69+
* The client consults the layer directly only for the not-initialized short-circuit.
70+
*
71+
* @return the override layer, or null
72+
*/
73+
OverrideLayer getOverrideLayer();
6574
}
6675

6776
/**

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,11 @@ public String getEnvironmentId() {
160160
return dataStore.getEnvironmentId();
161161
}
162162

163+
@Override
164+
public OverrideLayer getOverrideLayer() {
165+
return null;
166+
}
167+
163168
@Override
164169
public void close() throws IOException {
165170
if (disposed) {

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

Lines changed: 56 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
import com.launchdarkly.sdk.server.subsystems.DataStore;
1616
import com.launchdarkly.sdk.server.subsystems.LoggingConfiguration;
1717
import com.launchdarkly.sdk.server.subsystems.DataSystemConfiguration;
18+
import com.launchdarkly.sdk.server.subsystems.OverrideSource;
1819

1920
import java.io.Closeable;
2021
import java.io.IOException;
@@ -34,21 +35,44 @@ final class FDv2DataSystem implements DataSystem, Closeable {
3435
private final FlagChangeNotifier flagChanged;
3536
private final DataSourceStatusProvider dataSourceStatusProvider;
3637
private final DataStoreStatusProvider dataStoreStatusProvider;
38+
private final LDLogger logger;
39+
// The following are non-null only when an override source is configured. The layer holds the
40+
// override entries. The overlay serves them in preference to the store's data at the store read
41+
// boundary. The source populates the layer at runtime. None of them take part in
42+
// initialization or data source status.
43+
private final OverrideLayer overrideLayer;
44+
private final OverrideSource overrideSource;
45+
private final OverrideSinkImpl overrideSink;
3746
private boolean disposed = false;
3847

3948
private FDv2DataSystem(
4049
DataStore store,
4150
DataSource dataSource,
4251
DataSourceStatusProvider dataSourceStatusProvider,
4352
DataStoreStatusProvider dataStoreStatusProvider,
44-
FlagChangeNotifier flagChanged
53+
FlagChangeNotifier flagChanged,
54+
EventBroadcasterImpl<FlagChangeListener, FlagChangeEvent> flagChangeBroadcaster,
55+
OverrideSource overrideSource,
56+
LDLogger logger
4557
) {
4658
this.store = store;
4759
this.dataSource = dataSource;
4860
this.dataStoreStatusProvider = dataStoreStatusProvider;
4961
this.dataSourceStatusProvider = dataSourceStatusProvider;
5062
this.flagChanged = flagChanged;
51-
this.readOnlyStore = new ReadonlyStoreFacade(store);
63+
this.logger = logger;
64+
ReadOnlyStore baseStore = new ReadonlyStoreFacade(store);
65+
this.overrideSource = overrideSource;
66+
if (overrideSource == null) {
67+
this.overrideLayer = null;
68+
this.overrideSink = null;
69+
this.readOnlyStore = baseStore;
70+
} else {
71+
this.overrideLayer = new OverrideLayer();
72+
this.overrideSink = new OverrideSinkImpl(overrideLayer, baseStore, flagChangeBroadcaster,
73+
logger.subLogger(Loggers.DATA_SOURCE_LOGGER_NAME));
74+
this.readOnlyStore = new OverrideOverlayStore(baseStore, overrideLayer);
75+
}
5276
}
5377

5478
private static class FactoryWrapper<TDataSource> implements FDv2DataSource.DataSourceFactory<TDataSource> {
@@ -187,12 +211,23 @@ static FDv2DataSystem create(
187211

188212
FlagChangeNotifier flagChanged = new FlagChangedFacade(dataSourceUpdates);
189213

214+
// The override source is built like any other component. Invalid configuration fails here, the
215+
// same way an invalid data source or data store configuration fails. An offline client starts
216+
// no data sources and no override source.
217+
OverrideSource overrideSource = null;
218+
if (dataSystemConfiguration.getOverrideSource() != null && !config.offline) {
219+
overrideSource = dataSystemConfiguration.getOverrideSource().build(clientContext);
220+
}
221+
190222
return new FDv2DataSystem(
191223
store,
192224
dataSource,
193225
dataSourceStatusProvider,
194226
dataStoreStatusProvider,
195-
flagChanged
227+
flagChanged,
228+
flagChangeBroadcaster,
229+
overrideSource,
230+
logger
196231
);
197232
}
198233

@@ -203,6 +238,12 @@ public ReadOnlyStore getStore() {
203238

204239
@Override
205240
public Future<Void> start() {
241+
if (overrideSource != null) {
242+
// The source starts before the data source, so a source that loads synchronously has its
243+
// overrides in place before the client begins evaluating. Its initial load is part of
244+
// starting the client.
245+
overrideSource.start(overrideSink);
246+
}
206247
return dataSource.start();
207248
}
208249

@@ -231,12 +272,24 @@ public String getEnvironmentId() {
231272
return store.getEnvironmentId();
232273
}
233274

275+
@Override
276+
public OverrideLayer getOverrideLayer() {
277+
return overrideLayer;
278+
}
279+
234280
@Override
235281
public void close() throws IOException {
236282
if (disposed) {
237283
return;
238284
}
239285
try {
286+
if (overrideSource != null) {
287+
try {
288+
overrideSource.close();
289+
} catch (IOException | RuntimeException e) {
290+
logger.warn("Error closing override source: {}", e.toString());
291+
}
292+
}
240293
dataSource.close();
241294
store.close();
242295
} finally {

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

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -276,15 +276,19 @@ public Builder add(
276276
}
277277

278278
Builder addFlag(DataModel.FeatureFlag flag, EvalResult eval) {
279+
// A consumer of this state sends individual events according to the tracking fields. An
280+
// override-affected evaluation produces no individual events, so the state turns them off
281+
// for this flag. The flag, its value, its version, and its reason stay.
282+
boolean overrideAffected = eval.isOverrideAffected();
279283
return add(
280284
flag.getKey(),
281285
eval.getValue(),
282286
eval.isNoVariation() ? null : eval.getVariationIndex(),
283287
eval.getReason(),
284288
flag.getVersion(),
285-
flag.isTrackEvents() || eval.isForceReasonTracking(),
286-
eval.isForceReasonTracking(),
287-
flag.getDebugEventsUntilDate(),
289+
!overrideAffected && (flag.isTrackEvents() || eval.isForceReasonTracking()),
290+
!overrideAffected && eval.isForceReasonTracking(),
291+
overrideAffected ? null : flag.getDebugEventsUntilDate(),
288292
eval.getPrerequisiteEvalRecords().stream()
289293
.filter(record -> record.prereqOfFlag.getKey() == flag.getKey()) // only include top level prereqs
290294
.map(record -> record.flag.getKey()) // map from prereq record to prereq key

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

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
import javax.annotation.Nonnull;
1515
import javax.annotation.Nullable;
1616
import java.util.Map;
17+
import java.util.concurrent.atomic.AtomicBoolean;
1718

1819
import static com.launchdarkly.sdk.EvaluationDetail.NO_VARIATION;
1920
import static com.launchdarkly.sdk.server.DataModel.FEATURES;
@@ -28,7 +29,11 @@ class InputValidatingEvaluator implements EvaluatorInterface {
2829

2930
private final Evaluator evaluator;
3031
private final ReadOnlyStore store;
32+
// Null when no override source is configured. The store already applies the layer. The layer is
33+
// consulted directly only for the not-initialized short-circuit.
34+
private final OverrideLayer overrideLayer;
3135
private final LDLogger logger;
36+
private final AtomicBoolean allFlagsOverridesOnlyWarningLogged = new AtomicBoolean(false);
3237

3338
// these are created at construction to avoid recreation during each evaluation
3439
private final EvaluationRecorder evaluationEventRecorderWithDetails;
@@ -40,11 +45,14 @@ class InputValidatingEvaluator implements EvaluatorInterface {
4045
* Creates an {@link InputValidatingEvaluator}
4146
*
4247
* @param store will be used to get flag data
48+
* @param overrideLayer the override layer, or null if no override source is configured
4349
* @param segmentStore will be used to get segment data
4450
* @param eventProcessor will be used to record events during evaluations as necessary
4551
* @param logger for logging messages and errors during evaluations
4652
*/
47-
InputValidatingEvaluator(ReadOnlyStore store, BigSegmentStoreWrapper segmentStore, @Nonnull EventProcessor eventProcessor, LDLogger logger) {
53+
InputValidatingEvaluator(ReadOnlyStore store, @Nullable OverrideLayer overrideLayer, BigSegmentStoreWrapper segmentStore,
54+
@Nonnull EventProcessor eventProcessor, LDLogger logger) {
55+
this.overrideLayer = overrideLayer;
4856
this.evaluator = new Evaluator(new Evaluator.Getters() {
4957
public DataModel.FeatureFlag getFlag(String key) {
5058
return InputValidatingEvaluator.getFlag(store, key);
@@ -101,7 +109,9 @@ public EvalResultAndFlag evalAndFlag(String method, String flagKey, LDContext co
101109
*/
102110
EvalResultAndFlag evaluate(String flagKey, LDContext context, LDValue defaultValue,
103111
@Nullable LDValueType requireType, EvaluationRecorder recorder) {
104-
if (!store.isInitialized()) {
112+
// The override layer is consulted before the not-initialized short-circuit. A flag that the
113+
// layer holds is served from it. Any other flag returns the not-ready default as before.
114+
if (!store.isInitialized() && !hasOverrideFor(flagKey)) {
105115
logger.warn("Evaluation called before client initialized for feature flag \"{}\"; data store unavailable, returning default value", flagKey);
106116
recorder.recordEvaluationUnknownFlagError(flagKey, context, defaultValue, ErrorKind.CLIENT_NOT_READY);
107117
return new EvalResultAndFlag(EvalResult.error(ErrorKind.CLIENT_NOT_READY, defaultValue), null);
@@ -163,8 +173,16 @@ public FeatureFlagsState allFlagsState(LDContext context, FlagsStateOption... op
163173
FeatureFlagsState.Builder builder = FeatureFlagsState.builder(options);
164174

165175
if (!store.isInitialized()) {
166-
logger.warn("allFlagsState() was called before client initialized; data store unavailable, returning no data");
167-
return builder.valid(false).build();
176+
// With no LaunchDarkly data, the store read below returns only the entries that the override
177+
// layer holds. The resulting state contains only those flags.
178+
if (overrideLayer == null || overrideLayer.isEmpty()) {
179+
logger.warn("allFlagsState() was called before client initialized; data store unavailable, returning no data");
180+
return builder.valid(false).build();
181+
}
182+
if (allFlagsOverridesOnlyWarningLogged.compareAndSet(false, true)) {
183+
logger.warn("allFlagsState() was called before client initialized; returning only flags from the override layer."
184+
+ " This message is logged once.");
185+
}
168186
}
169187

170188
if (context == null) {
@@ -214,6 +232,14 @@ private static DataModel.FeatureFlag getFlag(ReadOnlyStore store, String key) {
214232
return item == null ? null : (DataModel.FeatureFlag) item.getItem();
215233
}
216234

235+
private boolean hasOverrideFor(String flagKey) {
236+
if (overrideLayer == null) {
237+
return false;
238+
}
239+
DataStoreTypes.ItemDescriptor item = overrideLayer.get(FEATURES, flagKey);
240+
return item != null && item.getItem() != null;
241+
}
242+
217243
private static DataModel.Segment getSegment(ReadOnlyStore store, String key) {
218244
DataStoreTypes.ItemDescriptor item = store.get(SEGMENTS, key);
219245
return item == null ? null : (DataModel.Segment) item.getItem();

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -209,7 +209,8 @@ public LDClient(String sdkKey, LDConfig config) {
209209
this.dataSystem = FDv1DataSystem.create(baseLogger, config, context, context.getLogging());
210210
}
211211

212-
EvaluatorInterface evaluator = new InputValidatingEvaluator(this.dataSystem.getStore(), bigSegmentStoreWrapper, eventProcessor, evaluationLogger);
212+
EvaluatorInterface evaluator = new InputValidatingEvaluator(this.dataSystem.getStore(),
213+
this.dataSystem.getOverrideLayer(), bigSegmentStoreWrapper, eventProcessor, evaluationLogger);
213214

214215
// build environment metadata for plugins
215216
SdkMetadata sdkMetadata;
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
package com.launchdarkly.sdk.server;
2+
3+
import com.google.common.collect.ImmutableMap;
4+
import com.launchdarkly.sdk.server.DataModel.FeatureFlag;
5+
import com.launchdarkly.sdk.server.DataModel.Segment;
6+
import com.launchdarkly.sdk.server.subsystems.DataStoreTypes.DataKind;
7+
import com.launchdarkly.sdk.server.subsystems.DataStoreTypes.ItemDescriptor;
8+
import com.launchdarkly.sdk.server.subsystems.DataStoreTypes.KeyedItems;
9+
10+
import java.util.HashMap;
11+
import java.util.Map;
12+
13+
/**
14+
* The override layer: a thread-safe store of override entries that an override source replaces
15+
* wholesale on each update. Entries take precedence over LaunchDarkly data at the store read
16+
* boundary; see {@link OverrideOverlayStore}.
17+
* <p>
18+
* Each entry is a marked shallow copy of the entity that the source supplied. The copy shares its
19+
* nested collections with the source's entity, and the layer never writes to them. The source's
20+
* entity is never marked, so a source may retain the entities it supplied and supply them again.
21+
* <p>
22+
* This class is package-private and should not be used by application code.
23+
*/
24+
final class OverrideLayer {
25+
/**
26+
* The previous and current contents of the layer after a replacement. The maps must not be
27+
* modified.
28+
*/
29+
static final class Replacement {
30+
final ImmutableMap<DataKind, ImmutableMap<String, ItemDescriptor>> previous;
31+
final ImmutableMap<DataKind, ImmutableMap<String, ItemDescriptor>> current;
32+
33+
Replacement(
34+
ImmutableMap<DataKind, ImmutableMap<String, ItemDescriptor>> previous,
35+
ImmutableMap<DataKind, ImmutableMap<String, ItemDescriptor>> current
36+
) {
37+
this.previous = previous;
38+
this.current = current;
39+
}
40+
}
41+
42+
// The contents are an immutable map that is swapped on each update, so the layer holds exactly
43+
// one snapshot at any instant and readers never take a lock.
44+
private volatile ImmutableMap<DataKind, ImmutableMap<String, ItemDescriptor>> contents = ImmutableMap.of();
45+
// A single volatile read decides the common case of a configured but unpopulated layer.
46+
private volatile boolean nonEmpty = false;
47+
private final Object writeLock = new Object();
48+
49+
/**
50+
* Atomically replaces the entire layer contents. A null or empty collection clears the layer.
51+
*
52+
* @param data the complete set of entries, grouped by data kind
53+
* @return the previous and the new contents
54+
*/
55+
Replacement setAll(Iterable<Map.Entry<DataKind, KeyedItems<ItemDescriptor>>> data) {
56+
Map<DataKind, Map<String, ItemDescriptor>> collected = new HashMap<>();
57+
int count = 0;
58+
if (data != null) {
59+
for (Map.Entry<DataKind, KeyedItems<ItemDescriptor>> kindEntry : data) {
60+
Map<String, ItemDescriptor> items = collected.computeIfAbsent(kindEntry.getKey(), k -> new HashMap<>());
61+
Iterable<Map.Entry<String, ItemDescriptor>> kindItems = kindEntry.getValue() == null
62+
? null : kindEntry.getValue().getItems();
63+
if (kindItems == null) {
64+
continue;
65+
}
66+
for (Map.Entry<String, ItemDescriptor> item : kindItems) {
67+
items.put(item.getKey(), markedCopy(item.getValue()));
68+
count++;
69+
}
70+
}
71+
}
72+
ImmutableMap.Builder<DataKind, ImmutableMap<String, ItemDescriptor>> builder = ImmutableMap.builder();
73+
for (Map.Entry<DataKind, Map<String, ItemDescriptor>> e : collected.entrySet()) {
74+
builder.put(e.getKey(), ImmutableMap.copyOf(e.getValue()));
75+
}
76+
ImmutableMap<DataKind, ImmutableMap<String, ItemDescriptor>> replacement = builder.build();
77+
78+
synchronized (writeLock) {
79+
ImmutableMap<DataKind, ImmutableMap<String, ItemDescriptor>> previous = contents;
80+
contents = replacement;
81+
nonEmpty = count != 0;
82+
return new Replacement(previous, replacement);
83+
}
84+
}
85+
86+
/**
87+
* Returns the override entry for a key, or null if the layer has none.
88+
*
89+
* @param kind the data kind
90+
* @param key the item key
91+
* @return the marked entry or null
92+
*/
93+
ItemDescriptor get(DataKind kind, String key) {
94+
if (!nonEmpty) {
95+
return null;
96+
}
97+
ImmutableMap<String, ItemDescriptor> items = contents.get(kind);
98+
return items == null ? null : items.get(key);
99+
}
100+
101+
/**
102+
* Returns the entries of a kind. The map must not be modified.
103+
*
104+
* @param kind the data kind
105+
* @return the entries, possibly empty
106+
*/
107+
ImmutableMap<String, ItemDescriptor> all(DataKind kind) {
108+
ImmutableMap<String, ItemDescriptor> items = contents.get(kind);
109+
return items == null ? ImmutableMap.of() : items;
110+
}
111+
112+
/**
113+
* Reports whether the layer contains no entries.
114+
*
115+
* @return true if empty
116+
*/
117+
boolean isEmpty() {
118+
return !nonEmpty;
119+
}
120+
121+
// Returns a copy of the descriptor whose item carries the override marker. An item of another
122+
// type, or a deleted item placeholder, is returned as is.
123+
static ItemDescriptor markedCopy(ItemDescriptor item) {
124+
Object entity = item.getItem();
125+
if (entity instanceof FeatureFlag) {
126+
return new ItemDescriptor(item.getVersion(), ((FeatureFlag) entity).markedAsOverride());
127+
}
128+
if (entity instanceof Segment) {
129+
return new ItemDescriptor(item.getVersion(), ((Segment) entity).markedAsOverride());
130+
}
131+
return item;
132+
}
133+
}

0 commit comments

Comments
 (0)