Skip to content

Commit 19f88eb

Browse files
committed
feat: Add the override store, overlay, and data system wiring
Adds the override layer described by the OVERRIDE specification: an override store that holds marked flag and segment definitions, an overlay at the store read boundary that returns the override entry for a key in preference to LaunchDarkly data and enumerates the union of both, and a sink that applies each snapshot as a single replacement and notifies flag change listeners of every flag whose evaluation may have changed, including flags that depend on a changed prerequisite or segment. The override source is an option of the FDv2 data system configuration: DataSystemConfig.override_source and ConfigBuilder.overrides(). The source is built with the data system, so invalid configuration raises from the client constructor. It starts before the data system's own threads, so its initial load completes before the constructor returns, and it is closed with the client. OverrideSource and OverrideSink are public protocols so a custom source can be supplied. The client consults the override store before its not-initialized short-circuit. An overridden flag is served before the client has LaunchDarkly data, and a flag absent from the override store still returns the client-not-ready default. The all-flags state reads through the overlay and, while the client has no LaunchDarkly data, contains only the overridden flags. The async client and data system receive the same changes, with override change notifications delivered on the event loop. The OVERRIDE specification test vectors run as a unit test through the full client stack.
1 parent ee313c1 commit 19f88eb

21 files changed

Lines changed: 1992 additions & 14 deletions

‎ldclient/async_client.py‎

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@ def __init__(self, config: AsyncConfig):
9595
# these flags, so plain booleans are safe.
9696
self._eval_cached_data_warned = False
9797
self._all_flags_cached_data_warned = False
98+
self._all_flags_overrides_only_warned = False
9899

99100
# Build the object graph here (loop-free). start() supplies the loop-bound
100101
# resources: the HTTP session (created lazily), the data source, the
@@ -498,17 +499,26 @@ async def _evaluate_internal(self, key: str, context: Context, default: Any, eve
498499
if self._config.offline:
499500
return EvaluationDetail(default, None, error_reason('CLIENT_NOT_READY')), None
500501

502+
def not_ready() -> Tuple[EvaluationDetail, Optional[FeatureFlag]]:
503+
log.warning("Feature Flag evaluation attempted before client has initialized! Feature store unavailable - returning default: " + str(default) + " for feature key: " + key)
504+
reason = error_reason('CLIENT_NOT_READY')
505+
self._send_event(event_factory.new_unknown_flag_event(key, context, default, reason))
506+
return EvaluationDetail(default, None, reason), None
507+
508+
no_launchdarkly_data = False
501509
availability = await self._data_system.data_availability()
502510
if availability != DataAvailability.REFRESHED:
503511
if availability == DataAvailability.CACHED:
504512
if not self._eval_cached_data_warned:
505513
self._eval_cached_data_warned = True
506514
log.warning("Feature Flag evaluation attempted before client has initialized - using last known values from feature store for feature key: " + key + ". This message is logged once.")
515+
elif self._data_system.override_source_configured:
516+
# No data from LaunchDarkly is available. The store read below still finds an
517+
# entry that the override layer holds, and the SDK serves it. A miss returns the
518+
# not-ready default.
519+
no_launchdarkly_data = True
507520
else:
508-
log.warning("Feature Flag evaluation attempted before client has initialized! Feature store unavailable - returning default: " + str(default) + " for feature key: " + key)
509-
reason = error_reason('CLIENT_NOT_READY')
510-
self._send_event(event_factory.new_unknown_flag_event(key, context, default, reason))
511-
return EvaluationDetail(default, None, reason), None
521+
return not_ready()
512522

513523
if not context.valid:
514524
log.warning("Context was invalid for flag evaluation (%s); returning default value" % context.error)
@@ -523,6 +533,8 @@ async def _evaluate_internal(self, key: str, context: Context, default: Any, eve
523533
self._send_event(event_factory.new_unknown_flag_event(key, context, default, reason))
524534
return EvaluationDetail(default, None, reason), None
525535
if not flag:
536+
if no_launchdarkly_data:
537+
return not_ready()
526538
reason = error_reason('FLAG_NOT_FOUND')
527539
self._send_event(event_factory.new_unknown_flag_event(key, context, default, reason))
528540
return EvaluationDetail(default, None, reason), None
@@ -572,12 +584,17 @@ async def all_flags_state(self, context: Context, **kwargs) -> FeatureFlagsState
572584
log.warning("all_flags_state() called, but client is in offline mode. Returning empty state")
573585
return FeatureFlagsState(False)
574586

587+
overrides_only = False
575588
availability = await self._data_system.data_availability()
576589
if availability != DataAvailability.REFRESHED:
577590
if availability == DataAvailability.CACHED:
578591
if not self._all_flags_cached_data_warned:
579592
self._all_flags_cached_data_warned = True
580593
log.warning("all_flags_state() called before client has finished initializing! Using last known values from feature store. This message is logged once.")
594+
elif self._data_system.override_source_configured:
595+
# No data from LaunchDarkly is available. The store read below returns only the
596+
# entries that the override layer holds. The result decides the state.
597+
overrides_only = True
581598
else:
582599
log.warning("all_flags_state() called before client has finished initializing! Feature store unavailable - returning empty state")
583600
return FeatureFlagsState(False)
@@ -598,6 +615,14 @@ async def all_flags_state(self, context: Context, **kwargs) -> FeatureFlagsState
598615
log.error("Unable to read flags for all_flag_state: %s" % repr(e))
599616
return FeatureFlagsState(False)
600617

618+
if overrides_only:
619+
if len(flags_map) == 0:
620+
log.warning("all_flags_state() called before client has finished initializing! Feature store unavailable - returning empty state")
621+
return FeatureFlagsState(False)
622+
if not self._all_flags_overrides_only_warned:
623+
self._all_flags_overrides_only_warned = True
624+
log.warning("all_flags_state() called before client has finished initializing! Returning only flags from the override layer. This message is logged once.")
625+
601626
for key, flag in flags_map.items():
602627
if client_only and not flag.get('clientSide', False):
603628
continue

‎ldclient/async_config.py‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
DataSourceBuilder,
2222
DataSourceBuilderConfig,
2323
HTTPConfig,
24+
OverrideSourceBuilder,
2425
PrivateAttributesConfig
2526
)
2627
from ldclient.hook import AsyncHook
@@ -127,6 +128,13 @@ class AsyncDataSystemConfig:
127128
fdv1_fallback_synchronizer: Optional[DataSourceBuilder[AsyncSynchronizer]] = None
128129
"""An optional fallback synchronizer that will read from FDv1"""
129130

131+
override_source: Optional[OverrideSourceBuilder] = None
132+
"""
133+
An optional override source. Its flag and segment definitions take precedence over
134+
LaunchDarkly data at evaluation time. Flag overrides are currently experimental and subject
135+
to change.
136+
"""
137+
130138

131139
class AsyncConfig(DataSourceBuilderConfig, PrivateAttributesConfig):
132140
"""Advanced configuration options for the async SDK client.

‎ldclient/client.py‎

Lines changed: 31 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,7 @@ def __init__(self, config: Config, start_wait: float = 5):
8989
self._cached_data_warning_lock = threading.Lock()
9090
self._eval_cached_data_warned = False
9191
self._all_flags_cached_data_warned = False
92+
self._all_flags_overrides_only_warned = False
9293

9394
self._owner_pid = os.getpid()
9495
self._fork_warned_pids: Dict[int, object] = {}
@@ -467,6 +468,13 @@ def _evaluate_internal(self, key: str, context: Context, default: Any, event_fac
467468
if self._config.offline:
468469
return EvaluationDetail(default, None, error_reason('CLIENT_NOT_READY')), None
469470

471+
def not_ready() -> Tuple[EvaluationDetail, Optional[FeatureFlag]]:
472+
log.warning("Feature Flag evaluation attempted before client has initialized! Feature store unavailable - returning default: " + str(default) + " for feature key: " + key)
473+
reason = error_reason('CLIENT_NOT_READY')
474+
self._send_event(event_factory.new_unknown_flag_event(key, context, default, reason))
475+
return EvaluationDetail(default, None, reason), None
476+
477+
no_launchdarkly_data = False
470478
availability = self._data_system.data_availability
471479
if availability != DataAvailability.REFRESHED:
472480
if availability == DataAvailability.CACHED:
@@ -475,11 +483,13 @@ def _evaluate_internal(self, key: str, context: Context, default: Any, event_fac
475483
if not self._eval_cached_data_warned:
476484
self._eval_cached_data_warned = True
477485
log.warning("Feature Flag evaluation attempted before client has initialized - using last known values from feature store for feature key: " + key + ". This message is logged once.")
486+
elif self._data_system.override_source_configured:
487+
# No data from LaunchDarkly is available. The store read below still finds an
488+
# entry that the override layer holds, and the SDK serves it. A miss returns the
489+
# not-ready default.
490+
no_launchdarkly_data = True
478491
else:
479-
log.warning("Feature Flag evaluation attempted before client has initialized! Feature store unavailable - returning default: " + str(default) + " for feature key: " + key)
480-
reason = error_reason('CLIENT_NOT_READY')
481-
self._send_event(event_factory.new_unknown_flag_event(key, context, default, reason))
482-
return EvaluationDetail(default, None, reason), None
492+
return not_ready()
483493

484494
if not context.valid:
485495
log.warning("Context was invalid for flag evaluation (%s); returning default value" % context.error)
@@ -494,6 +504,8 @@ def _evaluate_internal(self, key: str, context: Context, default: Any, event_fac
494504
self._send_event(event_factory.new_unknown_flag_event(key, context, default, reason))
495505
return EvaluationDetail(default, None, reason), None
496506
if not flag:
507+
if no_launchdarkly_data:
508+
return not_ready()
497509
reason = error_reason('FLAG_NOT_FOUND')
498510
self._send_event(event_factory.new_unknown_flag_event(key, context, default, reason))
499511
return EvaluationDetail(default, None, reason), None
@@ -545,6 +557,7 @@ def all_flags_state(self, context: Context, **kwargs) -> FeatureFlagsState:
545557

546558
self._check_forked()
547559

560+
overrides_only = False
548561
availability = self._data_system.data_availability
549562
if availability != DataAvailability.REFRESHED:
550563
if availability == DataAvailability.CACHED:
@@ -553,6 +566,10 @@ def all_flags_state(self, context: Context, **kwargs) -> FeatureFlagsState:
553566
if not self._all_flags_cached_data_warned:
554567
self._all_flags_cached_data_warned = True
555568
log.warning("all_flags_state() called before client has finished initializing! Using last known values from feature store. This message is logged once.")
569+
elif self._data_system.override_source_configured:
570+
# No data from LaunchDarkly is available. The store read below returns only the
571+
# entries that the override layer holds. The result decides the state.
572+
overrides_only = True
556573
else:
557574
log.warning("all_flags_state() called before client has finished initializing! Feature store unavailable - returning empty state")
558575
return FeatureFlagsState(False)
@@ -573,6 +590,16 @@ def all_flags_state(self, context: Context, **kwargs) -> FeatureFlagsState:
573590
log.error("Unable to read flags for all_flag_state: %s" % repr(e))
574591
return FeatureFlagsState(False)
575592

593+
if overrides_only:
594+
if len(flags_map) == 0:
595+
log.warning("all_flags_state() called before client has finished initializing! Feature store unavailable - returning empty state")
596+
return FeatureFlagsState(False)
597+
if not self._all_flags_overrides_only_warned:
598+
with self._cached_data_warning_lock:
599+
if not self._all_flags_overrides_only_warned:
600+
self._all_flags_overrides_only_warned = True
601+
log.warning("all_flags_state() called before client has finished initializing! Returning only flags from the override layer. This message is logged once.")
602+
576603
for key, flag in flags_map.items():
577604
if client_only and not flag.get('clientSide', False):
578605
continue

‎ldclient/config.py‎

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
EventProcessor,
2525
FeatureStore,
2626
Initializer,
27+
OverrideSource,
2728
Synchronizer,
2829
UpdateProcessor
2930
)
@@ -255,6 +256,23 @@ def build(self, config: DataSourceBuilderConfig) -> T_co:
255256
raise NotImplementedError
256257

257258

259+
class OverrideSourceBuilder(Protocol): # pylint: disable=too-few-public-methods
260+
"""
261+
Protocol for building an override source. Flag overrides are currently experimental and
262+
subject to change.
263+
"""
264+
265+
def build(self, config: DataSourceBuilderConfig) -> OverrideSource:
266+
"""
267+
Builds the override source. Invalid configuration raises an exception, which the client
268+
constructor propagates.
269+
270+
:param config: the SDK configuration
271+
:return: the built override source
272+
"""
273+
raise NotImplementedError
274+
275+
258276
@dataclass(frozen=True)
259277
class DataSystemConfig:
260278
"""Configuration for LaunchDarkly's data acquisition strategy."""
@@ -278,6 +296,13 @@ class DataSystemConfig:
278296
fdv1_fallback_synchronizer: Optional[DataSourceBuilder[Synchronizer]] = None
279297
"""An optional fallback synchronizer that will read from FDv1"""
280298

299+
override_source: Optional[OverrideSourceBuilder] = None
300+
"""
301+
An optional override source. Its flag and segment definitions take precedence over
302+
LaunchDarkly data at evaluation time. Flag overrides are currently experimental and subject
303+
to change.
304+
"""
305+
281306

282307
class Config(DataSourceBuilderConfig, PrivateAttributesConfig):
283308
"""Advanced configuration options for the SDK client.
@@ -715,4 +740,4 @@ def _validate(self):
715740
log.warning("Missing or blank SDK key")
716741

717742

718-
__all__ = ['Config', 'BigSegmentsConfig', 'DataSourceBuilder', 'DataSystemConfig', 'HTTPConfig']
743+
__all__ = ['Config', 'BigSegmentsConfig', 'DataSourceBuilder', 'DataSystemConfig', 'HTTPConfig', 'OverrideSourceBuilder']

‎ldclient/datasystem.py‎

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,11 @@
44

55
from typing import List, Optional
66

7-
from ldclient.config import DataSourceBuilder, DataSystemConfig
7+
from ldclient.config import (
8+
DataSourceBuilder,
9+
DataSystemConfig,
10+
OverrideSourceBuilder
11+
)
812
from ldclient.impl.datasourcev2.polling import (
913
FallbackToFDv1PollingDataSourceBuilder,
1014
PollingDataSourceBuilder
@@ -32,6 +36,7 @@ def __init__(self) -> None:
3236
self._fdv1_fallback_synchronizer: Optional[DataSourceBuilder[Synchronizer]] = None
3337
self._store_mode: DataStoreMode = DataStoreMode.READ_ONLY
3438
self._data_store: Optional[FeatureStore] = None
39+
self._override_source: Optional[OverrideSourceBuilder] = None
3540

3641
def initializers(self, initializers: Optional[List[DataSourceBuilder[Initializer]]]) -> "ConfigBuilder":
3742
"""
@@ -80,6 +85,35 @@ def data_store(self, data_store: FeatureStore, store_mode: DataStoreMode) -> "Co
8085
self._store_mode = store_mode
8186
return self
8287

88+
def overrides(self, source: OverrideSourceBuilder) -> "ConfigBuilder":
89+
"""
90+
Configures the SDK with an override source. Flag overrides are currently experimental
91+
and subject to change.
92+
93+
The source supplies flag and segment definitions that take precedence over data
94+
received from LaunchDarkly on a per-key basis. Overrides let an operator force one or
95+
more flags to a known state on a running client, whether or not the client can reach
96+
LaunchDarkly. Flags not present in the override data are unaffected.
97+
98+
The override source is not a data source. It has no effect on the client's
99+
initialization status or data source status. Configuring it changes nothing until the
100+
source actually supplies an override. At most one override source is accepted.
101+
102+
Example, with the file-based source and the SDK's default data sources:
103+
::
104+
105+
from ldclient import datasystem
106+
from ldclient.integrations.overrides import FileOverrideSourceBuilder
107+
108+
source = FileOverrideSourceBuilder(['/etc/launchdarkly/overrides.json'])
109+
config = Config(sdk_key, datasystem_config=datasystem.default().overrides(source).build())
110+
111+
:param source: a builder for the override source, such as
112+
:class:`ldclient.integrations.overrides.FileOverrideSourceBuilder`
113+
"""
114+
self._override_source = source
115+
return self
116+
83117
def build(self) -> DataSystemConfig:
84118
"""
85119
Builds the data system configuration.
@@ -90,6 +124,7 @@ def build(self) -> DataSystemConfig:
90124
fdv1_fallback_synchronizer=self._fdv1_fallback_synchronizer,
91125
data_store_mode=self._store_mode,
92126
data_store=self._data_store,
127+
override_source=self._override_source,
93128
)
94129

95130

‎ldclient/impl/datasystem/__init__.py‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,17 @@ def environment_id(self) -> Optional[str]:
158158
"""
159159
raise NotImplementedError
160160

161+
@property
162+
@abstractmethod
163+
def override_source_configured(self) -> bool:
164+
"""
165+
Reports whether the data system was built with an override source. The value is
166+
fixed at construction. When true, the store served by :attr:`store` overlays the
167+
override layer, and the client serves an overridden flag before it has LaunchDarkly
168+
data.
169+
"""
170+
raise NotImplementedError
171+
161172

162173
class AsyncDataSystem(Protocol):
163174
"""
@@ -232,6 +243,15 @@ def store(self) -> AsyncReadOnlyStore:
232243
"""
233244
raise NotImplementedError
234245

246+
@property
247+
@abstractmethod
248+
def override_source_configured(self) -> bool:
249+
"""
250+
Reports whether the data system was built with an override source. The value is
251+
fixed at construction.
252+
"""
253+
raise NotImplementedError
254+
235255

236256
class DiagnosticAccumulator(Protocol):
237257
def record_stream_init(self, timestamp, duration, failed):

‎ldclient/impl/datasystem/async_fdv1.py‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,11 @@ async def stop(self):
124124
def store(self) -> AsyncReadOnlyStore:
125125
return self._store_view
126126

127+
@property
128+
def override_source_configured(self) -> bool:
129+
# Overrides are an option of the FDv2 data system only.
130+
return False
131+
127132
def set_diagnostic_accumulator(self, diagnostic_accumulator: DiagnosticAccumulator):
128133
"""
129134
Sets the diagnostic accumulator for streaming initialization metrics.

0 commit comments

Comments
 (0)