From 087eedc6c7132300d1bfd526a9fede19efe10b33 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Fri, 25 Sep 2026 15:11:09 -0700 Subject: [PATCH 1/4] 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. --- ldclient/async_client.py | 33 +- ldclient/async_config.py | 8 + ldclient/client.py | 35 +- ldclient/config.py | 27 +- ldclient/datasystem.py | 37 +- ldclient/impl/datasystem/__init__.py | 20 + ldclient/impl/datasystem/async_fdv1.py | 5 + ldclient/impl/datasystem/async_fdv2.py | 53 ++- ldclient/impl/datasystem/fdv1.py | 5 + ldclient/impl/datasystem/fdv2.py | 49 ++- ldclient/impl/overrides/__init__.py | 15 + ldclient/impl/overrides/layer.py | 69 ++++ ldclient/impl/overrides/overlay.py | 85 ++++ ldclient/impl/overrides/sink.py | 147 +++++++ ldclient/interfaces.py | 67 ++++ ldclient/testing/impl/test_overrides.py | 376 ++++++++++++++++++ ldclient/testing/mock_components.py | 126 +++++- .../testing/test_async_client_overrides.py | 193 +++++++++ .../testing/test_ldclient_override_vectors.py | 85 ++++ ldclient/testing/test_ldclient_overrides.py | 255 ++++++++++++ .../testdata/override-vectors/vectors.json | 316 +++++++++++++++ 21 files changed, 1992 insertions(+), 14 deletions(-) create mode 100644 ldclient/impl/overrides/__init__.py create mode 100644 ldclient/impl/overrides/layer.py create mode 100644 ldclient/impl/overrides/overlay.py create mode 100644 ldclient/impl/overrides/sink.py create mode 100644 ldclient/testing/impl/test_overrides.py create mode 100644 ldclient/testing/test_async_client_overrides.py create mode 100644 ldclient/testing/test_ldclient_override_vectors.py create mode 100644 ldclient/testing/test_ldclient_overrides.py create mode 100644 ldclient/testing/testdata/override-vectors/vectors.json diff --git a/ldclient/async_client.py b/ldclient/async_client.py index b741aa17..d04f2ab6 100644 --- a/ldclient/async_client.py +++ b/ldclient/async_client.py @@ -95,6 +95,7 @@ def __init__(self, config: AsyncConfig): # these flags, so plain booleans are safe. self._eval_cached_data_warned = False self._all_flags_cached_data_warned = False + self._all_flags_overrides_only_warned = False # Build the object graph here (loop-free). start() supplies the loop-bound # 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 if self._config.offline: return EvaluationDetail(default, None, error_reason('CLIENT_NOT_READY')), None + def not_ready() -> Tuple[EvaluationDetail, Optional[FeatureFlag]]: + log.warning("Feature Flag evaluation attempted before client has initialized! Feature store unavailable - returning default: " + str(default) + " for feature key: " + key) + reason = error_reason('CLIENT_NOT_READY') + self._send_event(event_factory.new_unknown_flag_event(key, context, default, reason)) + return EvaluationDetail(default, None, reason), None + + no_launchdarkly_data = False availability = await self._data_system.data_availability() if availability != DataAvailability.REFRESHED: if availability == DataAvailability.CACHED: if not self._eval_cached_data_warned: self._eval_cached_data_warned = True 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.") + elif self._data_system.override_source_configured: + # No data from LaunchDarkly is available. The store read below still finds an + # entry that the override layer holds, and the SDK serves it. A miss returns the + # not-ready default. + no_launchdarkly_data = True else: - log.warning("Feature Flag evaluation attempted before client has initialized! Feature store unavailable - returning default: " + str(default) + " for feature key: " + key) - reason = error_reason('CLIENT_NOT_READY') - self._send_event(event_factory.new_unknown_flag_event(key, context, default, reason)) - return EvaluationDetail(default, None, reason), None + return not_ready() if not context.valid: 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 self._send_event(event_factory.new_unknown_flag_event(key, context, default, reason)) return EvaluationDetail(default, None, reason), None if not flag: + if no_launchdarkly_data: + return not_ready() reason = error_reason('FLAG_NOT_FOUND') self._send_event(event_factory.new_unknown_flag_event(key, context, default, reason)) return EvaluationDetail(default, None, reason), None @@ -572,12 +584,17 @@ async def all_flags_state(self, context: Context, **kwargs) -> FeatureFlagsState log.warning("all_flags_state() called, but client is in offline mode. Returning empty state") return FeatureFlagsState(False) + overrides_only = False availability = await self._data_system.data_availability() if availability != DataAvailability.REFRESHED: if availability == DataAvailability.CACHED: if not self._all_flags_cached_data_warned: self._all_flags_cached_data_warned = True log.warning("all_flags_state() called before client has finished initializing! Using last known values from feature store. This message is logged once.") + elif self._data_system.override_source_configured: + # No data from LaunchDarkly is available. The store read below returns only the + # entries that the override layer holds. The result decides the state. + overrides_only = True else: log.warning("all_flags_state() called before client has finished initializing! Feature store unavailable - returning empty state") return FeatureFlagsState(False) @@ -598,6 +615,14 @@ async def all_flags_state(self, context: Context, **kwargs) -> FeatureFlagsState log.error("Unable to read flags for all_flag_state: %s" % repr(e)) return FeatureFlagsState(False) + if overrides_only: + if len(flags_map) == 0: + log.warning("all_flags_state() called before client has finished initializing! Feature store unavailable - returning empty state") + return FeatureFlagsState(False) + if not self._all_flags_overrides_only_warned: + self._all_flags_overrides_only_warned = True + log.warning("all_flags_state() called before client has finished initializing! Returning only flags from the override layer. This message is logged once.") + for key, flag in flags_map.items(): if client_only and not flag.get('clientSide', False): continue diff --git a/ldclient/async_config.py b/ldclient/async_config.py index b46b362e..8537d02f 100644 --- a/ldclient/async_config.py +++ b/ldclient/async_config.py @@ -21,6 +21,7 @@ DataSourceBuilder, DataSourceBuilderConfig, HTTPConfig, + OverrideSourceBuilder, PrivateAttributesConfig ) from ldclient.hook import AsyncHook @@ -127,6 +128,13 @@ class AsyncDataSystemConfig: fdv1_fallback_synchronizer: Optional[DataSourceBuilder[AsyncSynchronizer]] = None """An optional fallback synchronizer that will read from FDv1""" + override_source: Optional[OverrideSourceBuilder] = None + """ + An optional override source. Its flag and segment definitions take precedence over + LaunchDarkly data at evaluation time. Flag overrides are currently experimental and subject + to change. + """ + class AsyncConfig(DataSourceBuilderConfig, PrivateAttributesConfig): """Advanced configuration options for the async SDK client. diff --git a/ldclient/client.py b/ldclient/client.py index b4460bc0..f6067723 100644 --- a/ldclient/client.py +++ b/ldclient/client.py @@ -89,6 +89,7 @@ def __init__(self, config: Config, start_wait: float = 5): self._cached_data_warning_lock = threading.Lock() self._eval_cached_data_warned = False self._all_flags_cached_data_warned = False + self._all_flags_overrides_only_warned = False self._owner_pid = os.getpid() self._fork_warned_pids: Dict[int, object] = {} @@ -467,6 +468,13 @@ def _evaluate_internal(self, key: str, context: Context, default: Any, event_fac if self._config.offline: return EvaluationDetail(default, None, error_reason('CLIENT_NOT_READY')), None + def not_ready() -> Tuple[EvaluationDetail, Optional[FeatureFlag]]: + log.warning("Feature Flag evaluation attempted before client has initialized! Feature store unavailable - returning default: " + str(default) + " for feature key: " + key) + reason = error_reason('CLIENT_NOT_READY') + self._send_event(event_factory.new_unknown_flag_event(key, context, default, reason)) + return EvaluationDetail(default, None, reason), None + + no_launchdarkly_data = False availability = self._data_system.data_availability if availability != DataAvailability.REFRESHED: if availability == DataAvailability.CACHED: @@ -475,11 +483,13 @@ def _evaluate_internal(self, key: str, context: Context, default: Any, event_fac if not self._eval_cached_data_warned: self._eval_cached_data_warned = True 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.") + elif self._data_system.override_source_configured: + # No data from LaunchDarkly is available. The store read below still finds an + # entry that the override layer holds, and the SDK serves it. A miss returns the + # not-ready default. + no_launchdarkly_data = True else: - log.warning("Feature Flag evaluation attempted before client has initialized! Feature store unavailable - returning default: " + str(default) + " for feature key: " + key) - reason = error_reason('CLIENT_NOT_READY') - self._send_event(event_factory.new_unknown_flag_event(key, context, default, reason)) - return EvaluationDetail(default, None, reason), None + return not_ready() if not context.valid: 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 self._send_event(event_factory.new_unknown_flag_event(key, context, default, reason)) return EvaluationDetail(default, None, reason), None if not flag: + if no_launchdarkly_data: + return not_ready() reason = error_reason('FLAG_NOT_FOUND') self._send_event(event_factory.new_unknown_flag_event(key, context, default, reason)) return EvaluationDetail(default, None, reason), None @@ -545,6 +557,7 @@ def all_flags_state(self, context: Context, **kwargs) -> FeatureFlagsState: self._check_forked() + overrides_only = False availability = self._data_system.data_availability if availability != DataAvailability.REFRESHED: if availability == DataAvailability.CACHED: @@ -553,6 +566,10 @@ def all_flags_state(self, context: Context, **kwargs) -> FeatureFlagsState: if not self._all_flags_cached_data_warned: self._all_flags_cached_data_warned = True log.warning("all_flags_state() called before client has finished initializing! Using last known values from feature store. This message is logged once.") + elif self._data_system.override_source_configured: + # No data from LaunchDarkly is available. The store read below returns only the + # entries that the override layer holds. The result decides the state. + overrides_only = True else: log.warning("all_flags_state() called before client has finished initializing! Feature store unavailable - returning empty state") return FeatureFlagsState(False) @@ -573,6 +590,16 @@ def all_flags_state(self, context: Context, **kwargs) -> FeatureFlagsState: log.error("Unable to read flags for all_flag_state: %s" % repr(e)) return FeatureFlagsState(False) + if overrides_only: + if len(flags_map) == 0: + log.warning("all_flags_state() called before client has finished initializing! Feature store unavailable - returning empty state") + return FeatureFlagsState(False) + if not self._all_flags_overrides_only_warned: + with self._cached_data_warning_lock: + if not self._all_flags_overrides_only_warned: + self._all_flags_overrides_only_warned = True + log.warning("all_flags_state() called before client has finished initializing! Returning only flags from the override layer. This message is logged once.") + for key, flag in flags_map.items(): if client_only and not flag.get('clientSide', False): continue diff --git a/ldclient/config.py b/ldclient/config.py index d7ac76a2..b6bb7ff5 100644 --- a/ldclient/config.py +++ b/ldclient/config.py @@ -24,6 +24,7 @@ EventProcessor, FeatureStore, Initializer, + OverrideSource, Synchronizer, UpdateProcessor ) @@ -255,6 +256,23 @@ def build(self, config: DataSourceBuilderConfig) -> T_co: raise NotImplementedError +class OverrideSourceBuilder(Protocol): # pylint: disable=too-few-public-methods + """ + Protocol for building an override source. Flag overrides are currently experimental and + subject to change. + """ + + def build(self, config: DataSourceBuilderConfig) -> OverrideSource: + """ + Builds the override source. Invalid configuration raises an exception, which the client + constructor propagates. + + :param config: the SDK configuration + :return: the built override source + """ + raise NotImplementedError + + @dataclass(frozen=True) class DataSystemConfig: """Configuration for LaunchDarkly's data acquisition strategy.""" @@ -278,6 +296,13 @@ class DataSystemConfig: fdv1_fallback_synchronizer: Optional[DataSourceBuilder[Synchronizer]] = None """An optional fallback synchronizer that will read from FDv1""" + override_source: Optional[OverrideSourceBuilder] = None + """ + An optional override source. Its flag and segment definitions take precedence over + LaunchDarkly data at evaluation time. Flag overrides are currently experimental and subject + to change. + """ + class Config(DataSourceBuilderConfig, PrivateAttributesConfig): """Advanced configuration options for the SDK client. @@ -715,4 +740,4 @@ def _validate(self): log.warning("Missing or blank SDK key") -__all__ = ['Config', 'BigSegmentsConfig', 'DataSourceBuilder', 'DataSystemConfig', 'HTTPConfig'] +__all__ = ['Config', 'BigSegmentsConfig', 'DataSourceBuilder', 'DataSystemConfig', 'HTTPConfig', 'OverrideSourceBuilder'] diff --git a/ldclient/datasystem.py b/ldclient/datasystem.py index d3a61a54..14b95a7e 100644 --- a/ldclient/datasystem.py +++ b/ldclient/datasystem.py @@ -4,7 +4,11 @@ from typing import List, Optional -from ldclient.config import DataSourceBuilder, DataSystemConfig +from ldclient.config import ( + DataSourceBuilder, + DataSystemConfig, + OverrideSourceBuilder +) from ldclient.impl.datasourcev2.polling import ( FallbackToFDv1PollingDataSourceBuilder, PollingDataSourceBuilder @@ -32,6 +36,7 @@ def __init__(self) -> None: self._fdv1_fallback_synchronizer: Optional[DataSourceBuilder[Synchronizer]] = None self._store_mode: DataStoreMode = DataStoreMode.READ_ONLY self._data_store: Optional[FeatureStore] = None + self._override_source: Optional[OverrideSourceBuilder] = None def initializers(self, initializers: Optional[List[DataSourceBuilder[Initializer]]]) -> "ConfigBuilder": """ @@ -80,6 +85,35 @@ def data_store(self, data_store: FeatureStore, store_mode: DataStoreMode) -> "Co self._store_mode = store_mode return self + def overrides(self, source: OverrideSourceBuilder) -> "ConfigBuilder": + """ + Configures the SDK with 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 actually supplies an override. At most one override source is accepted. + + Example, with the file-based source and the SDK's default data sources: + :: + + from ldclient import datasystem + from ldclient.integrations.overrides import FileOverrideSourceBuilder + + source = FileOverrideSourceBuilder(['/etc/launchdarkly/overrides.json']) + config = Config(sdk_key, datasystem_config=datasystem.default().overrides(source).build()) + + :param source: a builder for the override source, such as + :class:`ldclient.integrations.overrides.FileOverrideSourceBuilder` + """ + self._override_source = source + return self + def build(self) -> DataSystemConfig: """ Builds the data system configuration. @@ -90,6 +124,7 @@ def build(self) -> DataSystemConfig: fdv1_fallback_synchronizer=self._fdv1_fallback_synchronizer, data_store_mode=self._store_mode, data_store=self._data_store, + override_source=self._override_source, ) diff --git a/ldclient/impl/datasystem/__init__.py b/ldclient/impl/datasystem/__init__.py index 26be520d..88f1f7b7 100644 --- a/ldclient/impl/datasystem/__init__.py +++ b/ldclient/impl/datasystem/__init__.py @@ -158,6 +158,17 @@ def environment_id(self) -> Optional[str]: """ raise NotImplementedError + @property + @abstractmethod + def override_source_configured(self) -> bool: + """ + Reports whether the data system was built with an override source. The value is + fixed at construction. When true, the store served by :attr:`store` overlays the + override layer, and the client serves an overridden flag before it has LaunchDarkly + data. + """ + raise NotImplementedError + class AsyncDataSystem(Protocol): """ @@ -232,6 +243,15 @@ def store(self) -> AsyncReadOnlyStore: """ raise NotImplementedError + @property + @abstractmethod + def override_source_configured(self) -> bool: + """ + Reports whether the data system was built with an override source. The value is + fixed at construction. + """ + raise NotImplementedError + class DiagnosticAccumulator(Protocol): def record_stream_init(self, timestamp, duration, failed): diff --git a/ldclient/impl/datasystem/async_fdv1.py b/ldclient/impl/datasystem/async_fdv1.py index 96fbc660..b8b762f1 100644 --- a/ldclient/impl/datasystem/async_fdv1.py +++ b/ldclient/impl/datasystem/async_fdv1.py @@ -124,6 +124,11 @@ async def stop(self): def store(self) -> AsyncReadOnlyStore: return self._store_view + @property + def override_source_configured(self) -> bool: + # Overrides are an option of the FDv2 data system only. + return False + def set_diagnostic_accumulator(self, diagnostic_accumulator: DiagnosticAccumulator): """ Sets the diagnostic accumulator for streaming initialization metrics. diff --git a/ldclient/impl/datasystem/async_fdv2.py b/ldclient/impl/datasystem/async_fdv2.py index 612fe4cb..9f6d93db 100644 --- a/ldclient/impl/datasystem/async_fdv2.py +++ b/ldclient/impl/datasystem/async_fdv2.py @@ -38,6 +38,11 @@ ) from ldclient.impl.datasystem.store import _decode from ldclient.impl.listeners import Listeners +from ldclient.impl.overrides import ( + AsyncOverrideStoreView, + OverrideLayer, + OverrideSinkImpl +) from ldclient.impl.util import _LD_FD_FALLBACK_HEADER, _Fail, log from ldclient.interfaces import ( AsyncFeatureStore, @@ -47,7 +52,9 @@ DataSourceErrorKind, DataSourceState, DataStoreMode, - DataStoreStatus + DataStoreStatus, + FlagChange, + OverrideSource ) from ldclient.versioned_data_kind import VersionedDataKind @@ -270,6 +277,17 @@ def __init__( self._store_view = _AsyncReadOnlyStoreView(self._store) + # Set only when an override source is configured. See the sync FDv2 for the roles of + # the layer, the overlay, and the source. Building the source here makes an invalid + # source configuration a construction error. + self._override_source: Optional[OverrideSource] = None + self._override_layer: Optional[OverrideLayer] = None + self._overlay: Optional[AsyncOverrideStoreView] = None + if data_system_config.override_source is not None and not self._disabled: + self._override_source = data_system_config.override_source.build(config) + self._override_layer = OverrideLayer() + self._overlay = AsyncOverrideStoreView(self._store_view, self._override_layer) + # Concurrency self._stop_event = AsyncEvent() self._lock = AsyncLock() @@ -314,11 +332,33 @@ def start(self, set_on_ready: AsyncEvent): self._stop_event.clear() + if self._override_source is not None and self._override_layer is not None: + # The source runs on its own threads. Flag change notifications are delivered on + # the event loop, like every other change notification of the async client. The + # merged view for the change computation reads the in-memory store, which is the + # only store that can be read synchronously. While a persistent store is the + # active store, the dependency fan-out is limited to the in-memory data, and the + # directly changed keys are still notified. + loop = asyncio.get_running_loop() + listeners = self._flag_change_listeners + + def notify(key: str) -> None: + loop.call_soon_threadsafe(listeners.notify, FlagChange(key)) + + sink = OverrideSinkImpl(self._override_layer, self._store._memory_store, notify, listeners.has_listeners) + self._override_source.start(sink) + # Start the main coordination loop self._runner.spawn("AsyncFDv2-main", lambda: self._run_main_loop(set_on_ready)) async def stop(self): """Stop the AsyncFDv2 data system and all the work it is coordinating.""" + if self._override_source is not None: + try: + self._override_source.close() + except Exception as e: + log.error("Error closing the override source: %s", e) + self._stop_event.set() async with self._lock: @@ -628,9 +668,18 @@ async def reader(): @property def store(self) -> AsyncReadOnlyStore: - """Get the underlying store for flag evaluation.""" + """ + Get the store for flag evaluation. When an override source is configured, this is the + overlay that serves override entries in preference to LaunchDarkly data. + """ + if self._overlay is not None: + return self._overlay return self._store_view + @property + def override_source_configured(self) -> bool: + return self._override_source is not None + async def data_availability(self) -> DataAvailability: # type: ignore[override] """Reports what form of data is currently available, awaiting the store's readiness so a persistent store populated by another process is recognized diff --git a/ldclient/impl/datasystem/fdv1.py b/ldclient/impl/datasystem/fdv1.py index 09ca6939..dabaa12d 100644 --- a/ldclient/impl/datasystem/fdv1.py +++ b/ldclient/impl/datasystem/fdv1.py @@ -233,6 +233,11 @@ def stop(self): def store(self) -> ReadOnlyStore: return self._store_view + @property + def override_source_configured(self) -> bool: + # Overrides are an option of the FDv2 data system only. + return False + @property def environment_id(self) -> Optional[str]: return self._data_source_update_sink.environment_id diff --git a/ldclient/impl/datasystem/fdv2.py b/ldclient/impl/datasystem/fdv2.py index 75f8d28c..4d5398fb 100644 --- a/ldclient/impl/datasystem/fdv2.py +++ b/ldclient/impl/datasystem/fdv2.py @@ -16,6 +16,11 @@ ) from ldclient.impl.datasystem.store import Store, _decode from ldclient.impl.listeners import Listeners +from ldclient.impl.overrides import ( + OverrideLayer, + OverrideSinkImpl, + OverrideStoreView +) from ldclient.impl.repeating_task import RepeatingTask from ldclient.impl.rwlock import ReadWriteLock from ldclient.impl.util import _LD_FD_FALLBACK_HEADER, _Fail, log @@ -26,6 +31,8 @@ DataStoreMode, DataStoreStatus, FeatureStore, + FlagChange, + OverrideSource, ReadOnlyStore, Synchronizer ) @@ -257,6 +264,20 @@ def __init__( self._store_view = _ReadOnlyStoreView(self._store) + # The following are set 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. The override system + # has no effect on initialization status or data availability. Building the source + # here makes an invalid source configuration a construction error, like any other + # invalid component configuration. + self._override_source: Optional[OverrideSource] = None + self._override_layer: Optional[OverrideLayer] = None + self._overlay: Optional[OverrideStoreView] = None + if data_system_config.override_source is not None and not self._disabled: + self._override_source = data_system_config.override_source.build(config) + self._override_layer = OverrideLayer() + self._overlay = OverrideStoreView(self._store_view, self._override_layer) + def _create_store(self, flag_change_listeners: Listeners, change_set_listeners: Listeners) -> Store: return Store(flag_change_listeners, change_set_listeners) @@ -288,6 +309,17 @@ def start(self, set_on_ready: Event): self._stop_event.clear() + if self._override_source is not None and self._override_layer is not None: + # The source starts before the main thread, so a source that loads synchronously + # has its overrides in place before the client begins evaluating. + sink = OverrideSinkImpl( + self._override_layer, + self._store_view, + lambda key: self._flag_change_listeners.notify(FlagChange(key)), + self._flag_change_listeners.has_listeners, + ) + self._override_source.start(sink) + # Start the main coordination thread main_thread = Thread( target=self._run_main_loop, @@ -300,6 +332,12 @@ def start(self, set_on_ready: Event): def stop(self): """Stop the FDv2 data system and all associated threads.""" + if self._override_source is not None: + try: + self._override_source.close() + except Exception as e: + log.error("Error closing the override source: %s", e) + self._stop_event.set() with self._lock.write(): @@ -633,9 +671,18 @@ def environment_id(self) -> Optional[str]: @property def store(self) -> ReadOnlyStore: - """Get the underlying store for flag evaluation.""" + """ + Get the store for flag evaluation. When an override source is configured, this is the + overlay that serves override entries in preference to LaunchDarkly data. + """ + if self._overlay is not None: + return self._overlay return self._store_view + @property + def override_source_configured(self) -> bool: + return self._override_source is not None + __all__ = [ 'ConditionDirective', diff --git a/ldclient/impl/overrides/__init__.py b/ldclient/impl/overrides/__init__.py new file mode 100644 index 00000000..b3e7126e --- /dev/null +++ b/ldclient/impl/overrides/__init__.py @@ -0,0 +1,15 @@ +""" +The flag and segment override layer. The layer is a runtime-mutable collection of flag and +segment definitions supplied by an override source. Those definitions take precedence over +LaunchDarkly data at evaluation time. Flag overrides are currently experimental and subject to +change. +""" + +from ldclient.impl.overrides.layer import OverrideLayer +from ldclient.impl.overrides.overlay import ( + AsyncOverrideStoreView, + OverrideStoreView +) +from ldclient.impl.overrides.sink import OverrideSinkImpl + +__all__ = ['AsyncOverrideStoreView', 'OverrideLayer', 'OverrideSinkImpl', 'OverrideStoreView'] diff --git a/ldclient/impl/overrides/layer.py b/ldclient/impl/overrides/layer.py new file mode 100644 index 00000000..d03dc7af --- /dev/null +++ b/ldclient/impl/overrides/layer.py @@ -0,0 +1,69 @@ +""" +The override store. It holds the entries an override source has loaded, keyed by flag key or +segment key, and is replaced wholesale on each update. +""" + +from typing import Any, Dict, Mapping, Optional, Tuple + +from ldclient.impl.model import ModelEntity +from ldclient.impl.rwlock import ReadWriteLock +from ldclient.versioned_data_kind import FEATURES, SEGMENTS, VersionedDataKind + +LayerContents = Dict[VersionedDataKind, Dict[str, ModelEntity]] + + +def _empty_contents() -> LayerContents: + return {FEATURES: {}, SEGMENTS: {}} + + +class OverrideLayer: + """ + A thread-safe store of override entries, replaced wholesale on each update from an override + source. Each entry is a marked shallow copy of the definition the source supplied, so the + source's own objects are never marked and a source may retain and resupply them. + """ + + def __init__(self): + self._lock = ReadWriteLock() + self._contents: LayerContents = _empty_contents() + # Read without the lock on every evaluation. A single attribute read is atomic, so the + # per-evaluation cost of a configured but unpopulated override layer is negligible. + self._non_empty = False + + def set_all(self, flags: Mapping[str, Any], segments: Mapping[str, Any]) -> Tuple[LayerContents, LayerContents]: + """ + Atomically replaces the entire layer contents. Empty mappings clear the layer. + + Values may be model objects or their JSON dictionary form. A dictionary is decoded with + the model constructor, which raises ``ValueError`` for an invalid definition, and nothing + is replaced in that case. + + :return: the previous and the new contents. The returned dictionaries must not be modified. + """ + replacement: LayerContents = { + FEATURES: {key: FEATURES.decode(item).with_override_marker() for key, item in flags.items()}, + SEGMENTS: {key: SEGMENTS.decode(item).with_override_marker() for key, item in segments.items()}, + } + count = len(replacement[FEATURES]) + len(replacement[SEGMENTS]) + with self._lock.write(): + previous = self._contents + self._contents = replacement + self._non_empty = count > 0 + return previous, replacement + + def get(self, kind: VersionedDataKind, key: str) -> Optional[ModelEntity]: + """Returns the override entry for a key, or None when the layer has none.""" + if not self._non_empty: + return None + with self._lock.read(): + return self._contents.get(kind, {}).get(key) + + def all(self, kind: VersionedDataKind) -> Dict[str, ModelEntity]: + """Returns the entries of the given kind. The returned dictionary must not be modified.""" + with self._lock.read(): + return self._contents.get(kind, {}) + + @property + def is_empty(self) -> bool: + """True when the layer holds no entries.""" + return not self._non_empty diff --git a/ldclient/impl/overrides/overlay.py b/ldclient/impl/overrides/overlay.py new file mode 100644 index 00000000..685352d6 --- /dev/null +++ b/ldclient/impl/overrides/overlay.py @@ -0,0 +1,85 @@ +""" +The overlay at the store read boundary. A read for a key returns the override entry when one +exists and the LaunchDarkly entry otherwise. Targeting rules, prerequisites, and segment matches +behave identically for overridden and ordinary data, because they are the same reads through +the same boundary. +""" + +from typing import Any, Callable, Dict, Optional + +from ldclient.impl.overrides.layer import OverrideLayer +from ldclient.interfaces import AsyncReadOnlyStore, ReadOnlyStore +from ldclient.versioned_data_kind import VersionedDataKind + + +def _merge_all(base_items: Optional[Dict[str, Any]], override_items: Dict[str, Any]) -> Dict[str, Any]: + """The union of the base items and the override items. The override entry wins for a key present in both.""" + if base_items is None: + return dict(override_items) + if len(override_items) == 0: + return base_items + merged = dict(base_items) + merged.update(override_items) + return merged + + +class OverrideStoreView(ReadOnlyStore): + """ + Merges an override layer over a base read-only store. + + A per-key read serves an override entry whatever the state of the base store, so an + uninitialized base still serves overrides. An enumeration is the union of the base items + and the layer's items, and the override entry wins for a key present in both, including a + key the base holds as a deleted item. When the base enumeration fails and the layer holds + entries, the layer's entries alone are returned. When the layer is empty, the failure is + raised as before. + """ + + def __init__(self, base: ReadOnlyStore, layer: OverrideLayer): + self._base = base + self._layer = layer + + def get(self, kind: VersionedDataKind, key: str, callback: Callable[[Any], Any] = lambda x: x) -> Any: + item = self._layer.get(kind, key) + if item is not None: + return callback(item) + return self._base.get(kind, key, callback) + + def all(self, kind: VersionedDataKind, callback: Callable[[Any], Any] = lambda x: x) -> Any: + override_items = self._layer.all(kind) + try: + base_items = self._base.all(kind, lambda x: x) + except Exception: + if len(override_items) == 0: + raise + base_items = None + return callback(_merge_all(base_items, override_items)) + + @property + def initialized(self) -> bool: + # The override layer never affects initialization status or data availability. + return self._base.initialized + + +class AsyncOverrideStoreView(AsyncReadOnlyStore): + """The async counterpart of :class:`OverrideStoreView`, over an async base store.""" + + def __init__(self, base: AsyncReadOnlyStore, layer: OverrideLayer): + self._base = base + self._layer = layer + + async def get(self, kind: VersionedDataKind, key: str) -> Optional[Any]: + item = self._layer.get(kind, key) + if item is not None: + return item + return await self._base.get(kind, key) + + async def all(self, kind: VersionedDataKind) -> Dict[str, Any]: + override_items = self._layer.all(kind) + try: + base_items: Optional[Dict[str, Any]] = await self._base.all(kind) + except Exception: + if len(override_items) == 0: + raise + base_items = None + return _merge_all(base_items, override_items) diff --git a/ldclient/impl/overrides/sink.py b/ldclient/impl/overrides/sink.py new file mode 100644 index 00000000..03866dc9 --- /dev/null +++ b/ldclient/impl/overrides/sink.py @@ -0,0 +1,147 @@ +""" +The sink that applies override snapshots to the layer and notifies flag change listeners of the +flags whose evaluation may have changed. +""" + +import threading +from typing import Any, Callable, Dict, Optional, Set + +from ldclient.impl.dependency_tracker import DependencyTracker, KindAndKey +from ldclient.impl.overrides.layer import LayerContents, OverrideLayer +from ldclient.impl.util import log +from ldclient.interfaces import OverrideSink, ReadOnlyStore +from ldclient.versioned_data_kind import FEATURES, SEGMENTS, VersionedDataKind + +MergedView = Dict[VersionedDataKind, Dict[str, Any]] + +_DIFF_KINDS = (FEATURES, SEGMENTS) + + +class OverrideSinkImpl(OverrideSink): + """ + Applies override layer replacements supplied by an override source, then notifies flag + change listeners of every flag whose merged-view evaluation may have changed. Calls are + serialized, so overlapping updates from a source cannot interleave. + """ + + def __init__( + self, + layer: OverrideLayer, + base: ReadOnlyStore, + notify: Callable[[str], None], + has_listeners: Callable[[], bool], + ): + """ + :param layer: the override layer to write to + :param base: the store holding LaunchDarkly data, without the overlay. Merged-view + snapshots for change computation are built from it plus the layer. + :param notify: receives the key of each affected flag + :param has_listeners: reports whether anything listens for flag changes, so the change + computation can be skipped when nothing does + """ + self._layer = layer + self._base = base + self._notify = notify + self._has_listeners = has_listeners + self._lock = threading.Lock() + + def set_overrides(self, flags: Dict[str, Any], segments: Dict[str, Any]) -> None: # type: ignore[override] + with self._lock: + if not self._has_listeners(): + self._layer.set_all(flags, segments) + return + + previous, current = self._layer.set_all(flags, segments) + old_merged = snapshot_merged_view(self._base, previous) + new_merged = snapshot_merged_view(self._base, current) + affected = compute_affected_flags(previous, current, old_merged, new_merged) + if len(affected) > 0: + log.debug("Override update affected %d flag(s)", len(affected)) + for key in sorted(affected): + self._notify(key) + + +def snapshot_merged_view(base: ReadOnlyStore, overrides: LayerContents) -> MergedView: + """ + Captures the merged view of a base store and a layer snapshot: base data with the 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. + """ + view: MergedView = {} + for kind in _DIFF_KINDS: + items: Dict[str, Any] = {} + try: + base_items = base.all(kind, lambda x: x) + if base_items is not None: + items.update(base_items) + except Exception as e: + log.debug("Unable to read %s for override change computation: %s", kind.namespace, e) + items.update(overrides.get(kind, {})) + view[kind] = items + return view + + +def compute_affected_flags( + old_overrides: LayerContents, + new_overrides: LayerContents, + old_merged: MergedView, + new_merged: MergedView, +) -> Set[str]: + """ + Returns the keys of all flags whose merged-view evaluation may have changed when the + override 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. + """ + seeds = diff_overrides(old_overrides, new_overrides) + if len(seeds) == 0: + return set() + + # 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. + old_tracker = _tracker_from_view(old_merged) + new_tracker = _tracker_from_view(new_merged) + affected: Set[KindAndKey] = set() + for seed in seeds: + old_tracker.add_affected_items(affected, seed) + new_tracker.add_affected_items(affected, seed) + return {item.key for item in affected if item.kind == FEATURES} + + +def diff_overrides(old_overrides: LayerContents, new_overrides: LayerContents) -> Set[KindAndKey]: + """ + Returns a key for each entry whose override differs between the two layer snapshots. An + added or removed entry is always a change, even when its content matches the underlying + LaunchDarkly data, because the override marker alone changes the served entry. Entries + present in both snapshots are compared by version and definition. The layer is rebuilt + wholesale on every update, so identity comparison would report every retained entry as + changed. + """ + seeds: Set[KindAndKey] = set() + for kind in _DIFF_KINDS: + old_items = old_overrides.get(kind, {}) + new_items = new_overrides.get(kind, {}) + for key, old_item in old_items.items(): + new_item = new_items.get(key) + if new_item is None or not _items_equal(old_item, new_item): + seeds.add(KindAndKey(kind=kind, key=key)) + for key in new_items: + if key not in old_items: + seeds.add(KindAndKey(kind=kind, key=key)) + return seeds + + +def _items_equal(a: Any, b: Any) -> bool: + if a.version != b.version: + return False + return a.to_json_dict() == b.to_json_dict() + + +def _tracker_from_view(view: MergedView) -> DependencyTracker: + tracker = DependencyTracker() + for kind in _DIFF_KINDS: + for key, item in view.get(kind, {}).items(): + tracker.update_dependencies_from(kind, key, item) + return tracker diff --git a/ldclient/interfaces.py b/ldclient/interfaces.py index 2c9c245f..90e6e549 100644 --- a/ldclient/interfaces.py +++ b/ldclient/interfaces.py @@ -2128,6 +2128,73 @@ def stop(self): raise NotImplementedError +class OverrideSink(Protocol): # pylint: disable=too-few-public-methods + """ + OverrideSink receives the contents of the SDK's flag and segment override layer. The SDK + implements it and passes it to an :class:`OverrideSource` when the source starts. Override + sources call it, they do not implement it. + + Flag overrides are currently experimental and subject to change. + """ + + @abstractmethod + def set_overrides(self, flags: Mapping[str, Any], segments: Mapping[str, Any]) -> None: + """ + Replaces the entire override layer with the given flag and segment definitions. Each call + is a full snapshot: entries absent from the call are removed from the layer, and empty + mappings clear the layer. The new contents are visible to evaluations when the call + returns. + + The mappings are keyed by flag key and segment key. Each value is a full definition, + either a :class:`ldclient.impl.model.FeatureFlag` or :class:`ldclient.impl.model.Segment` + model object or its JSON dictionary form. A dictionary that is not a valid definition + raises ``ValueError``. The SDK itself marks the entries as overrides. + + This method is safe to call from any thread. Calls are serialized by the SDK. + + :param flags: the flag definitions, keyed by flag key + :param segments: the segment definitions, keyed by segment key + """ + raise NotImplementedError + + +class OverrideSource(Protocol): + """ + OverrideSource 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 participate in the data system's + initializer and synchronizer pipeline. The override layer it populates has no effect on the + client's initialization status, data availability, or data source status. + + To configure an override source, use :meth:`ldclient.datasystem.ConfigBuilder.overrides`. + + Flag overrides are currently experimental and subject to change. + """ + + @abstractmethod + def start(self, sink: OverrideSink) -> None: + """ + 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 + :meth:`close` is called. A failed load leaves the previously supplied layer untouched by + not calling the sink. + + :param sink: the sink that receives each snapshot + """ + raise NotImplementedError + + @abstractmethod + def close(self) -> None: + """ + Stops the source and releases any resources it holds. + """ + raise NotImplementedError + + class AsyncInitializer(Protocol): # pylint: disable=too-few-public-methods """ AsyncInitializer represents a component capable of retrieving a single data result diff --git a/ldclient/testing/impl/test_overrides.py b/ldclient/testing/impl/test_overrides.py new file mode 100644 index 00000000..6da55c2d --- /dev/null +++ b/ldclient/testing/impl/test_overrides.py @@ -0,0 +1,376 @@ +""" +Tests for the override layer, the overlay at the store read boundary, and the sink that applies +snapshots and notifies flag change listeners. +""" +from typing import Any, Dict, List, Optional + +import pytest + +from ldclient.impl.datasystem.store import InMemoryFeatureStore +from ldclient.impl.overrides import ( + AsyncOverrideStoreView, + OverrideLayer, + OverrideSinkImpl, + OverrideStoreView +) +from ldclient.testing.builders import ( + FlagBuilder, + FlagRuleBuilder, + SegmentBuilder, + make_clause_matching_segment_key +) +from ldclient.versioned_data_kind import FEATURES, SEGMENTS + + +class FakeBaseStore: + """A minimal read-only store with controllable contents, initialization state, and failures.""" + + def __init__(self, flags: Optional[Dict[str, Any]] = None, segments: Optional[Dict[str, Any]] = None, initialized: bool = True): + self.flags = dict(flags or {}) + self.segments = dict(segments or {}) + self._initialized = initialized + self.fail_all = False + + def _items(self, kind): + return self.flags if kind == FEATURES else self.segments + + def get(self, kind, key, callback=lambda x: x): + return callback(self._items(kind).get(key)) + + def all(self, kind, callback=lambda x: x): + if self.fail_all: + raise RuntimeError("store failure") + return callback(dict(self._items(kind))) + + @property + def initialized(self) -> bool: + return self._initialized + + +class FakeAsyncBaseStore: + def __init__(self, base: FakeBaseStore): + self._base = base + + async def get(self, kind, key): + return self._base.get(kind, key) + + async def all(self, kind): + return self._base.all(kind) + + +def flag(key: str, version: int = 1): + return FlagBuilder(key).version(version).on(True).variations(False, True).fallthrough_variation(1).off_variation(0).build() + + +def segment(key: str, version: int = 1): + return SegmentBuilder(key).version(version).build() + + +# --------------------------------------------------------------------------- +# Layer +# --------------------------------------------------------------------------- + +def test_layer_marks_copies_without_mutating_source(): + layer = OverrideLayer() + f = flag('flag1', 2) + s = segment('segment1', 3) + layer.set_all({'flag1': f}, {'segment1': s}) + + assert f.is_override is False + assert s.is_override is False + stored_flag = layer.get(FEATURES, 'flag1') + assert stored_flag is not None + assert stored_flag.is_override is True + assert stored_flag.version == 2 + stored_segment = layer.get(SEGMENTS, 'segment1') + assert stored_segment is not None + assert stored_segment.is_override is True + + +def test_layer_decodes_dictionaries_and_marks_them(): + layer = OverrideLayer() + layer.set_all({'flag1': flag('flag1').to_json_dict()}, {'segment1': segment('segment1').to_json_dict()}) + stored_flag = layer.get(FEATURES, 'flag1') + assert stored_flag is not None + assert stored_flag.is_override is True + assert stored_flag.on is True + stored_segment = layer.get(SEGMENTS, 'segment1') + assert stored_segment is not None + assert stored_segment.is_override is True + + +def test_layer_rejects_invalid_definitions_and_keeps_its_contents(): + layer = OverrideLayer() + layer.set_all({'flag1': flag('flag1')}, {}) + with pytest.raises(ValueError): + layer.set_all({'flag2': {'key': 'flag2', 'version': 'not a number'}}, {}) + assert layer.get(FEATURES, 'flag1') is not None + assert layer.get(FEATURES, 'flag2') is None + with pytest.raises(ValueError): + layer.set_all({}, {'seg': {'key': 'seg', 'version': 1, 'included': 'not a list'}}) + assert layer.get(FEATURES, 'flag1') is not None + + +def test_layer_replacement_semantics(): + layer = OverrideLayer() + assert layer.is_empty is True + assert layer.get(FEATURES, 'flag1') is None + + layer.set_all({'flag1': flag('flag1')}, {}) + assert layer.is_empty is False + assert layer.get(FEATURES, 'flag1') is not None + + # A replacement is a full snapshot: entries absent from it are removed. + layer.set_all({'flag2': flag('flag2')}, {}) + assert layer.get(FEATURES, 'flag1') is None + assert layer.get(FEATURES, 'flag2') is not None + + layer.set_all({}, {}) + assert layer.is_empty is True + assert layer.get(FEATURES, 'flag2') is None + + +def test_layer_set_all_returns_previous_and_current_contents(): + layer = OverrideLayer() + previous, current = layer.set_all({'flag1': flag('flag1')}, {'seg': segment('seg')}) + assert previous == {FEATURES: {}, SEGMENTS: {}} + assert list(current[FEATURES].keys()) == ['flag1'] + assert list(current[SEGMENTS].keys()) == ['seg'] + previous, current = layer.set_all({}, {}) + assert list(previous[FEATURES].keys()) == ['flag1'] + assert current == {FEATURES: {}, SEGMENTS: {}} + + +def test_layer_all_returns_entries_of_a_kind(): + layer = OverrideLayer() + layer.set_all({'a': flag('a'), 'b': flag('b')}, {'s': segment('s')}) + assert sorted(layer.all(FEATURES).keys()) == ['a', 'b'] + assert list(layer.all(SEGMENTS).keys()) == ['s'] + assert all(item.is_override for item in layer.all(FEATURES).values()) + + +# --------------------------------------------------------------------------- +# Overlay +# --------------------------------------------------------------------------- + +def test_overlay_get_precedence(): + base = FakeBaseStore(flags={'both': flag('both', 1), 'base-only': flag('base-only', 1)}) + layer = OverrideLayer() + layer.set_all({'both': flag('both', 99), 'override-only': flag('override-only', 1)}, {}) + overlay = OverrideStoreView(base, layer) + + item = overlay.get(FEATURES, 'both') + assert item.version == 99 + assert item.is_override is True + + item = overlay.get(FEATURES, 'base-only') + assert item.is_override is False + + item = overlay.get(FEATURES, 'override-only') + assert item.is_override is True + + assert overlay.get(FEATURES, 'nowhere') is None + assert overlay.get(FEATURES, 'both', lambda x: x.version) == 99 + + +def test_overlay_get_serves_overrides_from_uninitialized_base(): + base = FakeBaseStore(initialized=False) + layer = OverrideLayer() + layer.set_all({'flag1': flag('flag1')}, {}) + overlay = OverrideStoreView(base, layer) + item = overlay.get(FEATURES, 'flag1') + assert item.is_override is True + assert overlay.initialized is False + + +def test_overlay_get_all_union(): + memory = InMemoryFeatureStore() + memory.set_basis({ + FEATURES: { + 'both': flag('both', 1).to_json_dict(), + 'base-only': flag('base-only', 1).to_json_dict(), + 'tombstone': {'key': 'tombstone', 'version': 5, 'deleted': True}, + }, + SEGMENTS: {}, + }) + layer = OverrideLayer() + layer.set_all({'both': flag('both', 99), 'tombstone': flag('tombstone', 1), 'override-only': flag('override-only', 1)}, {}) + overlay = OverrideStoreView(memory, layer) + + items = overlay.all(FEATURES) + assert sorted(items.keys()) == ['base-only', 'both', 'override-only', 'tombstone'] + assert items['both'].version == 99 + assert items['both'].is_override is True + assert items['base-only'].is_override is False + assert items['tombstone'].is_override is True, "an override wins over a deleted item" + assert items['override-only'].is_override is True + assert overlay.all(FEATURES, lambda x: len(x)) == 4 + + +def test_overlay_get_all_with_empty_layer_is_passthrough(): + base = FakeBaseStore(flags={'flag1': flag('flag1')}) + overlay = OverrideStoreView(base, OverrideLayer()) + assert list(overlay.all(FEATURES).keys()) == ['flag1'] + base.fail_all = True + with pytest.raises(RuntimeError): + overlay.all(FEATURES) + + +def test_overlay_get_all_serves_overrides_when_base_fails(): + base = FakeBaseStore() + base.fail_all = True + layer = OverrideLayer() + layer.set_all({'override-1': flag('override-1', 1), 'override-2': flag('override-2', 2)}, {}) + overlay = OverrideStoreView(base, layer) + items = overlay.all(FEATURES) + assert sorted(items.keys()) == ['override-1', 'override-2'] + assert all(item.is_override for item in items.values()) + + +def test_overlay_initialized_follows_the_base(): + base = FakeBaseStore(initialized=True) + overlay = OverrideStoreView(base, OverrideLayer()) + assert overlay.initialized is True + base._initialized = False + assert overlay.initialized is False + + +@pytest.mark.asyncio +async def test_async_overlay_get_and_all(): + base = FakeBaseStore(flags={'both': flag('both', 1), 'base-only': flag('base-only', 1)}, initialized=False) + layer = OverrideLayer() + layer.set_all({'both': flag('both', 99), 'override-only': flag('override-only', 1)}, {'seg': segment('seg')}) + overlay = AsyncOverrideStoreView(FakeAsyncBaseStore(base), layer) + + both = await overlay.get(FEATURES, 'both') + assert both.version == 99 and both.is_override is True + base_only = await overlay.get(FEATURES, 'base-only') + assert base_only.is_override is False + assert (await overlay.get(FEATURES, 'nowhere')) is None + assert (await overlay.get(SEGMENTS, 'seg')).is_override is True + + items = await overlay.all(FEATURES) + assert sorted(items.keys()) == ['base-only', 'both', 'override-only'] + assert items['both'].version == 99 + + +@pytest.mark.asyncio +async def test_async_overlay_all_when_base_fails(): + base = FakeBaseStore() + base.fail_all = True + layer = OverrideLayer() + overlay = AsyncOverrideStoreView(FakeAsyncBaseStore(base), layer) + with pytest.raises(RuntimeError): + await overlay.all(FEATURES) + layer.set_all({'override-1': flag('override-1')}, {}) + items = await overlay.all(FEATURES) + assert list(items.keys()) == ['override-1'] + + +# --------------------------------------------------------------------------- +# Sink +# --------------------------------------------------------------------------- + +class SinkFixture: + def __init__(self, base: FakeBaseStore): + self.base = base + self.layer = OverrideLayer() + self.notified: List[str] = [] + self.listening = True + self.sink = OverrideSinkImpl(self.layer, base, lambda key: self.notified.append(key), lambda: self.listening) + + def take_notified(self) -> List[str]: + result = sorted(self.notified) + del self.notified[:] + return result + + +def test_sink_notifies_on_add_change_remove(): + base = FakeBaseStore(flags={'flag1': flag('flag1', 1)}) + f = SinkFixture(base) + + # Adding an override is a change even though flag1 also exists in base data. + f.sink.set_overrides({'flag1': flag('flag1', 1), 'flag2': flag('flag2', 1)}, {}) + assert f.take_notified() == ['flag1', 'flag2'] + + # An identical replacement (rebuilt from scratch, new objects) changes nothing. + f.sink.set_overrides({'flag1': flag('flag1', 1), 'flag2': flag('flag2', 1)}, {}) + assert f.take_notified() == [] + + # Changing one entry notifies only that entry. + f.sink.set_overrides({'flag1': flag('flag1', 1), 'flag2': flag('flag2', 2)}, {}) + assert f.take_notified() == ['flag2'] + + # A change in content at the same version is a change. + f.sink.set_overrides({'flag1': flag('flag1', 1), 'flag2': FlagBuilder('flag2').version(2).on(False).build()}, {}) + assert f.take_notified() == ['flag2'] + + # Removing overrides notifies them: flag1 reverts to base data, flag2 to not-found. + f.sink.set_overrides({}, {}) + assert f.take_notified() == ['flag1', 'flag2'] + + +def test_sink_segment_override_fans_out_to_dependent_flags(): + dependent = FlagBuilder('dependent').version(1).on(True).variations(True, False).fallthrough_variation(1).rules( + FlagRuleBuilder().id('r').variation(0).clauses(make_clause_matching_segment_key('segment1')).build() + ).build() + base = FakeBaseStore( + flags={'dependent': dependent, 'unrelated': flag('unrelated')}, + segments={'segment1': segment('segment1', 1)}, + ) + f = SinkFixture(base) + f.sink.set_overrides({}, {'segment1': segment('segment1', 99)}) + # The segment itself is not a flag, so only the dependent flag is notified. + assert f.take_notified() == ['dependent'] + + +def test_sink_prerequisite_override_fans_out_through_the_chain(): + top = FlagBuilder('top').version(1).on(True).variations(True, False).fallthrough_variation(0).prerequisite('mid', 0).build() + mid = FlagBuilder('mid').version(1).on(True).variations(True, False).fallthrough_variation(0).prerequisite('leaf', 0).build() + base = FakeBaseStore(flags={'top': top, 'mid': mid, 'leaf': flag('leaf'), 'other': flag('other')}) + f = SinkFixture(base) + f.sink.set_overrides({'leaf': flag('leaf', 5)}, {}) + assert f.take_notified() == ['leaf', 'mid', 'top'] + + +def test_sink_removing_a_segment_override_notifies_flags_that_reference_it(): + # The override of "dependent" references segment "s". Removing the override of "s" alone + # changes what "dependent" evaluates to, so "dependent" is notified through the dependency + # fan-out even though its own override did not change. + dependent_override = FlagBuilder('dependent').version(2).on(True).variations(True, False).fallthrough_variation(1).rules( + FlagRuleBuilder().variation(0).clauses(make_clause_matching_segment_key('s')).build() + ).build() + base = FakeBaseStore(flags={'dependent': flag('dependent')}, segments={'s': segment('s')}) + f = SinkFixture(base) + f.sink.set_overrides({'dependent': dependent_override}, {'s': segment('s', 2)}) + f.take_notified() + f.sink.set_overrides({'dependent': dependent_override}, {}) + assert f.take_notified() == ['dependent'] + + +def test_sink_skips_change_computation_when_nothing_listens(): + base = FakeBaseStore(flags={'flag1': flag('flag1')}) + f = SinkFixture(base) + f.listening = False + f.sink.set_overrides({'flag1': flag('flag1', 2)}, {}) + assert f.take_notified() == [] + assert f.layer.get(FEATURES, 'flag1').version == 2 + + +def test_sink_notifies_directly_changed_keys_when_base_read_fails(): + base = FakeBaseStore(flags={'flag1': flag('flag1')}) + base.fail_all = True + f = SinkFixture(base) + f.sink.set_overrides({'flag1': flag('flag1', 2)}, {}) + assert f.take_notified() == ['flag1'] + + +def test_sink_invalid_snapshot_raises_and_changes_nothing(): + base = FakeBaseStore() + f = SinkFixture(base) + f.sink.set_overrides({'flag1': flag('flag1')}, {}) + f.take_notified() + with pytest.raises(ValueError): + f.sink.set_overrides({'flag1': {'key': 'flag1', 'version': 'bad'}}, {}) + assert f.layer.get(FEATURES, 'flag1').version == 1 + assert f.take_notified() == [] diff --git a/ldclient/testing/mock_components.py b/ldclient/testing/mock_components.py index ad93b32b..59c257e1 100644 --- a/ldclient/testing/mock_components.py +++ b/ldclient/testing/mock_components.py @@ -1,9 +1,16 @@ +import threading import time -from typing import Callable +from typing import Any, Callable, Dict, Optional +from ldclient.impl.util import _Success from ldclient.interfaces import ( + Basis, BigSegmentStore, BigSegmentStoreMetadata, + ChangeSetBuilder, + IntentCode, + ObjectKind, + OverrideSink, Selector ) @@ -54,3 +61,120 @@ def __init__(self, selector: Selector): def selector(self) -> Selector: return self._selector + + +class MockOverrideSource: + """ + An override source for tests. It pushes its current contents to the sink when started and + on every later call to set_overrides, and records its lifecycle calls. + """ + + def __init__(self, flags: Optional[Dict[str, Any]] = None, segments: Optional[Dict[str, Any]] = None): + self._flags: Dict[str, Any] = dict(flags or {}) + self._segments: Dict[str, Any] = dict(segments or {}) + self._sink: Optional[OverrideSink] = None + self.start_count = 0 + self.close_count = 0 + + def start(self, sink: OverrideSink) -> None: + self.start_count += 1 + self._sink = sink + sink.set_overrides(self._flags, self._segments) + + def close(self) -> None: + self.close_count += 1 + + def set_overrides(self, flags: Optional[Dict[str, Any]] = None, segments: Optional[Dict[str, Any]] = None) -> None: + """Replaces the source's contents and, once started, pushes them to the sink.""" + self._flags = dict(flags or {}) + self._segments = dict(segments or {}) + if self._sink is not None: + self._sink.set_overrides(self._flags, self._segments) + + @property + def builder(self) -> 'MockOverrideSourceBuilder': + return MockOverrideSourceBuilder(self) + + +class MockOverrideSourceBuilder: + """Wraps a MockOverrideSource so it can be passed to the data system configuration.""" + + def __init__(self, source: MockOverrideSource): + self._source = source + self.build_count = 0 + + def build(self, config) -> MockOverrideSource: + self.build_count += 1 + return self._source + + +class FailingOverrideSourceBuilder: + """A builder whose build raises, to simulate invalid override source configuration.""" + + def build(self, config): + raise ValueError("invalid override source configuration") + + +class HangingSynchronizer: + """ + A synchronizer that connects but never yields data, so the client stays uninitialized + until it is stopped. + """ + + def __init__(self): + self._stop = threading.Event() + + @property + def name(self) -> str: + return "HangingSynchronizer" + + def sync(self, ss): + self._stop.wait() + yield from () + + def stop(self): + self._stop.set() + + @property + def builder(self) -> 'MockDataSourceBuilder': + return MockDataSourceBuilder(self) + + +class StaticInitializer: + """ + An initializer that supplies fixed flag and segment definitions as a full-transfer basis + with a defined selector, so the client reports full data availability once it has applied + the data. + """ + + def __init__(self, flags: Optional[Dict[str, dict]] = None, segments: Optional[Dict[str, dict]] = None): + self._flags = dict(flags or {}) + self._segments = dict(segments or {}) + + @property + def name(self) -> str: + return "StaticInitializer" + + def fetch(self, ss): + builder = ChangeSetBuilder() + builder.start(IntentCode.TRANSFER_FULL) + for key, flag in self._flags.items(): + builder.add_put(ObjectKind.FLAG, key, flag.get('version', 1), flag) + for key, segment in self._segments.items(): + builder.add_put(ObjectKind.SEGMENT, key, segment.get('version', 1), segment) + change_set = builder.finish(Selector.new_selector('test-state', 1)) + return _Success(Basis(change_set=change_set, persist=False, environment_id=None)) + + @property + def builder(self) -> 'MockDataSourceBuilder': + return MockDataSourceBuilder(self) + + +class MockDataSourceBuilder: + """Wraps a ready-made data source so it can be passed to the data system configuration.""" + + def __init__(self, source): + self._source = source + + def build(self, config): + return self._source diff --git a/ldclient/testing/test_async_client_overrides.py b/ldclient/testing/test_async_client_overrides.py new file mode 100644 index 00000000..7373e1db --- /dev/null +++ b/ldclient/testing/test_async_client_overrides.py @@ -0,0 +1,193 @@ +""" +Tests for flag overrides through the async client. These mirror the key scenarios of the sync +client tests. +""" +import asyncio +import threading +from typing import Any, Dict, Optional + +import pytest + +from ldclient.async_client import AsyncLDClient +from ldclient.async_config import AsyncConfig, AsyncDataSystemConfig +from ldclient.context import Context +from ldclient.impl.aio.concurrency import AsyncEvent +from ldclient.impl.integrations.files.filedata import make_flag_with_value +from ldclient.testing.builders import FlagBuilder +from ldclient.testing.mock_async_components import MockAsyncEventProcessor +from ldclient.testing.mock_components import ( + FailingOverrideSourceBuilder, + MockDataSourceBuilder, + MockOverrideSource, + StaticInitializer +) + +user = Context.create('user-key') + + +class AsyncHangingSynchronizer: + """An async synchronizer that connects but never yields data.""" + + def __init__(self): + self._stop = AsyncEvent() + + @property + def name(self) -> str: + return "AsyncHangingSynchronizer" + + async def sync(self, ss): + await self._stop.wait() + return + yield + + async def stop(self) -> None: + self._stop.set() + + +class AsyncStaticInitializer(StaticInitializer): + async def fetch(self, ss): # type: ignore[override] + return super().fetch(ss) + + +def single_value_flag(key: str, value: Any) -> dict: + return make_flag_with_value(key, value).to_json_dict() + + +async def make_uninitialized_client(source: MockOverrideSource) -> AsyncLDClient: + datasystem = AsyncDataSystemConfig(synchronizers=[MockDataSourceBuilder(AsyncHangingSynchronizer())], override_source=source.builder) + config = AsyncConfig('SDK_KEY', datasystem_config=datasystem, event_processor_class=lambda config: MockAsyncEventProcessor()) + client = AsyncLDClient(config) + await client.start(start_wait=0) + return client + + +async def make_initialized_client(flags: Dict[str, dict], source: MockOverrideSource, segments: Optional[Dict[str, dict]] = None) -> AsyncLDClient: + initializer = AsyncStaticInitializer(flags, segments or {}) + datasystem = AsyncDataSystemConfig(initializers=[MockDataSourceBuilder(initializer)], override_source=source.builder) + config = AsyncConfig('SDK_KEY', datasystem_config=datasystem, event_processor_class=lambda config: MockAsyncEventProcessor()) + client = AsyncLDClient(config) + await client.start(start_wait=5) + assert await client.is_initialized() is True + return client + + +@pytest.mark.asyncio +async def test_override_is_served_when_client_is_not_initialized(): + source = MockOverrideSource(flags={'overridden-flag': single_value_flag('overridden-flag', True)}) + client = await make_uninitialized_client(source) + try: + assert await client.is_initialized() is False + detail = await client.variation_detail('overridden-flag', user, False) + assert detail.value is True + assert detail.reason == {'kind': 'OFF', 'overrideAffected': True} + finally: + await client.close() + assert source.close_count == 1 + + +@pytest.mark.asyncio +async def test_non_overridden_flag_still_short_circuits_when_client_is_not_initialized(): + source = MockOverrideSource(flags={'overridden-flag': single_value_flag('overridden-flag', True)}) + client = await make_uninitialized_client(source) + try: + detail = await client.variation_detail('other-flag', user, False) + assert detail.value is False + assert detail.reason == {'kind': 'ERROR', 'errorKind': 'CLIENT_NOT_READY'} + finally: + await client.close() + + +@pytest.mark.asyncio +async def test_override_removal_restores_short_circuit(): + source = MockOverrideSource(flags={'overridden-flag': single_value_flag('overridden-flag', True)}) + client = await make_uninitialized_client(source) + try: + assert await client.variation('overridden-flag', user, False) is True + source.set_overrides({}, {}) + detail = await client.variation_detail('overridden-flag', user, False) + assert detail.reason == {'kind': 'ERROR', 'errorKind': 'CLIENT_NOT_READY'} + finally: + await client.close() + + +@pytest.mark.asyncio +async def test_invalid_override_source_configuration_fails_construction(): + datasystem = AsyncDataSystemConfig(synchronizers=[MockDataSourceBuilder(AsyncHangingSynchronizer())], override_source=FailingOverrideSourceBuilder()) + config = AsyncConfig('SDK_KEY', datasystem_config=datasystem, event_processor_class=lambda config: MockAsyncEventProcessor()) + with pytest.raises(ValueError): + AsyncLDClient(config) + + +@pytest.mark.asyncio +async def test_override_takes_precedence_over_launchdarkly_data_and_all_flags_reflects_it(): + ld_flag = FlagBuilder('flag-precedence').version(100).on(False).off_variation(0).variations('ld-value').build().to_json_dict() + normal = FlagBuilder('flag-normal').version(100).on(False).off_variation(0).variations('normal-value').build().to_json_dict() + source = MockOverrideSource(flags={'flag-precedence': single_value_flag('flag-precedence', 'override-value')}) + client = await make_initialized_client({'flag-precedence': ld_flag, 'flag-normal': normal}, source) + try: + detail = await client.variation_detail('flag-precedence', user, 'default') + assert detail.value == 'override-value' + assert detail.reason == {'kind': 'OFF', 'overrideAffected': True} + detail = await client.variation_detail('flag-normal', user, 'default') + assert detail.reason == {'kind': 'OFF'} + + state = await client.all_flags_state(user, with_reasons=True) + assert state.to_values_map() == {'flag-precedence': 'override-value', 'flag-normal': 'normal-value'} + assert state.get_flag_reason('flag-precedence') == {'kind': 'OFF', 'overrideAffected': True} + finally: + await client.close() + + +@pytest.mark.asyncio +async def test_all_flags_state_contains_only_overrides_when_client_is_not_initialized(): + source = MockOverrideSource(flags={'overridden-flag': single_value_flag('overridden-flag', True)}) + client = await make_uninitialized_client(source) + try: + state = await client.all_flags_state(user) + assert state.valid is True + assert state.to_values_map() == {'overridden-flag': True} + source.set_overrides({}, {}) + assert (await client.all_flags_state(user)).valid is False + finally: + await client.close() + + +@pytest.mark.asyncio +async def test_flag_tracker_is_notified_of_override_changes_on_the_event_loop(): + source = MockOverrideSource() + client = await make_uninitialized_client(source) + try: + changes: asyncio.Queue = asyncio.Queue() + loop_thread = threading.current_thread() + listener_threads = [] + + def listener(change): + listener_threads.append(threading.current_thread()) + changes.put_nowait(change) + + client.flag_tracker.add_listener(listener) + + # The source pushes from a worker thread, as the file source does. The notification + # is delivered on the event loop's thread. + await asyncio.to_thread(source.set_overrides, {'overridden-flag': single_value_flag('overridden-flag', True)}, {}) + change = await asyncio.wait_for(changes.get(), 5) + assert change.key == 'overridden-flag' + assert listener_threads[0] is loop_thread + finally: + await client.close() + + +@pytest.mark.asyncio +async def test_flag_value_change_listener_sees_override_value_changes(): + ld_flag = FlagBuilder('flag').version(100).on(False).off_variation(0).variations('ld-value').build().to_json_dict() + source = MockOverrideSource() + client = await make_initialized_client({'flag': ld_flag}, source) + try: + changes: asyncio.Queue = asyncio.Queue() + await client.flag_tracker.add_flag_value_change_listener('flag', user, changes.put_nowait) + await asyncio.to_thread(source.set_overrides, {'flag': single_value_flag('flag', 'override-value')}, {}) + change = await asyncio.wait_for(changes.get(), 5) + assert change.old_value == 'ld-value' + assert change.new_value == 'override-value' + finally: + await client.close() diff --git a/ldclient/testing/test_ldclient_override_vectors.py b/ldclient/testing/test_ldclient_override_vectors.py new file mode 100644 index 00000000..d9533c3d --- /dev/null +++ b/ldclient/testing/test_ldclient_override_vectors.py @@ -0,0 +1,85 @@ +""" +Runs the OVERRIDE specification test vectors. Each vector sets up LaunchDarkly data, an +override layer, and an initialization state. The test evaluates one flag through the full +client stack and checks the value, the variation index, and the reason. +""" +import json +import os +from typing import Any, Dict + +import pytest + +from ldclient.client import Config, Context, LDClient +from ldclient.datasystem import custom +from ldclient.impl.integrations.files.filedata import make_flag_with_value +from ldclient.testing.mock_components import ( + HangingSynchronizer, + MockOverrideSource, + StaticInitializer +) +from ldclient.testing.stub_util import MockEventProcessor + +VECTORS_PATH = os.path.join(os.path.dirname(__file__), 'testdata', 'override-vectors', 'vectors.json') + +# The vectors' semantics are versioned. A schema change means this runner needs review. +SUPPORTED_SCHEMA_VERSION = '0.4.0' + + +def load_vectors(): + with open(VECTORS_PATH, 'r') as f: + document = json.load(f) + assert document['schemaVersion'] == SUPPORTED_SCHEMA_VERSION, "the vectors changed schema; review this runner against the new schema before updating" + assert len(document['vectors']) > 0 + return document['vectors'] + + +def vector_id(vector: Dict[str, Any]) -> str: + return "%s: %s" % (vector['group'], vector['description']) + + +def override_flags(overrides: Dict[str, Any]) -> Dict[str, Any]: + flags = dict(overrides.get('flags', {})) + for key, value in overrides.get('flagValues', {}).items(): + flags[key] = make_flag_with_value(key, value) + return flags + + +def make_client(vector: Dict[str, Any]) -> LDClient: + source = MockOverrideSource(flags=override_flags(vector['overrides']), segments=dict(vector['overrides'].get('segments', {}))) + ld_data = vector['launchDarklyData'] + datasystem = custom().overrides(source.builder) + if ld_data['initialized']: + datasystem.initializers([StaticInitializer(ld_data.get('flags', {}), ld_data.get('segments', {})).builder]) + start_wait = 5 + else: + # With no sources at all, the client would consider cached data available rather than + # applying its not-initialized handling. A synchronizer that never delivers avoids that. + datasystem.synchronizers(HangingSynchronizer().builder) + start_wait = 0 + config = Config(sdk_key='SDK_KEY', datasystem_config=datasystem.build(), event_processor_class=MockEventProcessor) + return LDClient(config, start_wait=start_wait) + + +def assert_reason(expected: Dict[str, Any], actual: Dict[str, Any]) -> None: + """ + The reason is compared only on the fields present in the expected reason. The + override-affected indicator is a tri-state collapse: an expected reason that omits it + requires the actual reason to omit it or report it as false. + """ + for name, value in expected.items(): + assert actual.get(name) == value, "reason property %s: expected %r in %r" % (name, value, actual) + if 'overrideAffected' not in expected: + assert actual.get('overrideAffected', False) is False, "reason must not be override-affected: %r" % actual + + +@pytest.mark.parametrize("vector", load_vectors(), ids=vector_id) +def test_override_spec_vector(vector: Dict[str, Any]): + with make_client(vector) as client: + assert client.is_initialized() is vector['launchDarklyData']['initialized'] + evaluate = vector['evaluate'] + detail = client.variation_detail(evaluate['flagKey'], Context.from_dict(evaluate['context']), evaluate['defaultValue']) + + expect = vector['expect'] + assert detail.value == expect['value'], "value" + assert detail.variation_index == expect['variationIndex'], "variationIndex" + assert_reason(expect['reason'], detail.reason) diff --git a/ldclient/testing/test_ldclient_overrides.py b/ldclient/testing/test_ldclient_overrides.py new file mode 100644 index 00000000..030a603f --- /dev/null +++ b/ldclient/testing/test_ldclient_overrides.py @@ -0,0 +1,255 @@ +""" +Tests for flag overrides through the client: the override source lifecycle, the overlay at the +store read boundary, the not-initialized gate, the all-flags state, and flag change notifications. +""" +import logging +from queue import Empty, Queue +from typing import Any, Dict, Optional + +import pytest + +from ldclient.client import Config, Context, LDClient +from ldclient.datasystem import custom +from ldclient.evaluation import EvaluationDetail +from ldclient.impl.datasystem.fdv1 import FDv1 +from ldclient.impl.integrations.files.filedata import make_flag_with_value +from ldclient.interfaces import DataSourceState, FlagChange +from ldclient.testing.builders import ( + FlagBuilder, + FlagRuleBuilder, + SegmentBuilder, + make_clause_matching_segment_key +) +from ldclient.testing.mock_components import ( + FailingOverrideSourceBuilder, + HangingSynchronizer, + MockOverrideSource, + StaticInitializer +) +from ldclient.testing.stub_util import MockEventProcessor + +user = Context.create('user-key') + + +def single_value_flag(key: str, value: Any) -> dict: + return make_flag_with_value(key, value).to_json_dict() + + +def make_uninitialized_client(source: MockOverrideSource) -> LDClient: + """A client whose data system can never obtain LaunchDarkly data, with the given override source.""" + datasystem = custom().synchronizers(HangingSynchronizer().builder).overrides(source.builder).build() + config = Config(sdk_key='SDK_KEY', datasystem_config=datasystem, event_processor_class=MockEventProcessor) + return LDClient(config, start_wait=0) + + +def make_initialized_client(flags: Dict[str, dict], source: MockOverrideSource, segments: Optional[Dict[str, dict]] = None) -> LDClient: + """A client that has initialized with the given LaunchDarkly data, with the given override source.""" + initializer = StaticInitializer(flags, segments or {}) + datasystem = custom().initializers([initializer.builder]).overrides(source.builder).build() + config = Config(sdk_key='SDK_KEY', datasystem_config=datasystem, event_processor_class=MockEventProcessor) + client = LDClient(config, start_wait=5) + assert client.is_initialized() is True + return client + + +def warnings_containing(caplog, text: str): + return [r for r in caplog.records if r.levelno == logging.WARNING and text in r.getMessage()] + + +def test_override_is_served_when_client_is_not_initialized(): + source = MockOverrideSource(flags={'overridden-flag': single_value_flag('overridden-flag', True)}) + with make_uninitialized_client(source) as client: + assert client.is_initialized() is False + detail = client.variation_detail('overridden-flag', user, False) + assert detail.value is True + assert detail.variation_index == 0 + assert detail.reason == {'kind': 'OFF', 'overrideAffected': True} + + +def test_non_overridden_flag_still_short_circuits_when_client_is_not_initialized(): + source = MockOverrideSource(flags={'overridden-flag': single_value_flag('overridden-flag', True)}) + with make_uninitialized_client(source) as client: + detail = client.variation_detail('other-flag', user, False) + assert detail == EvaluationDetail(False, None, {'kind': 'ERROR', 'errorKind': 'CLIENT_NOT_READY'}) + + +def test_override_removal_restores_short_circuit(): + source = MockOverrideSource(flags={'overridden-flag': single_value_flag('overridden-flag', True)}) + with make_uninitialized_client(source) as client: + assert client.variation('overridden-flag', user, False) is True + source.set_overrides({}, {}) + detail = client.variation_detail('overridden-flag', user, False) + assert detail.value is False + assert detail.reason == {'kind': 'ERROR', 'errorKind': 'CLIENT_NOT_READY'} + + +def test_override_source_is_started_before_the_constructor_returns(): + source = MockOverrideSource(flags={'overridden-flag': single_value_flag('overridden-flag', True)}) + client = make_uninitialized_client(source) + try: + assert source.start_count == 1 + assert client.variation('overridden-flag', user, False) is True + finally: + client.close() + + +def test_override_source_is_closed_when_the_client_is_closed(): + source = MockOverrideSource() + client = make_uninitialized_client(source) + assert source.close_count == 0 + client.close() + assert source.close_count == 1 + + +def test_invalid_override_source_configuration_fails_construction(): + datasystem = custom().synchronizers(HangingSynchronizer().builder).overrides(FailingOverrideSourceBuilder()).build() + config = Config(sdk_key='SDK_KEY', datasystem_config=datasystem, event_processor_class=MockEventProcessor) + with pytest.raises(ValueError): + LDClient(config, start_wait=0) + + +def test_override_source_is_not_started_when_offline(): + source = MockOverrideSource(flags={'overridden-flag': single_value_flag('overridden-flag', True)}) + datasystem = custom().overrides(source.builder).build() + config = Config(sdk_key='SDK_KEY', datasystem_config=datasystem, offline=True) + with LDClient(config, start_wait=0) as client: + assert source.start_count == 0 + assert client.variation('overridden-flag', user, False) is False + + +def test_data_system_without_override_source_reports_none_configured(): + datasystem = custom().synchronizers(HangingSynchronizer().builder).build() + config = Config(sdk_key='SDK_KEY', datasystem_config=datasystem, event_processor_class=MockEventProcessor) + with LDClient(config, start_wait=0) as client: + assert client._data_system.override_source_configured is False + detail = client.variation_detail('any-flag', user, False) + assert detail.reason == {'kind': 'ERROR', 'errorKind': 'CLIENT_NOT_READY'} + + +def test_legacy_data_system_reports_no_override_source(): + config = Config(sdk_key='SDK_KEY', offline=True) + with LDClient(config) as client: + assert isinstance(client._data_system, FDv1) + assert client._data_system.override_source_configured is False + + +def test_override_takes_precedence_over_launchdarkly_data(): + ld_flag = FlagBuilder('flag-precedence').version(100).on(False).off_variation(0).variations('ld-value').build().to_json_dict() + normal = FlagBuilder('flag-normal').version(100).on(False).off_variation(0).variations('normal-value').build().to_json_dict() + source = MockOverrideSource(flags={'flag-precedence': single_value_flag('flag-precedence', 'override-value')}) + with make_initialized_client({'flag-precedence': ld_flag, 'flag-normal': normal}, source) as client: + detail = client.variation_detail('flag-precedence', user, 'default') + assert detail.value == 'override-value' + assert detail.reason == {'kind': 'OFF', 'overrideAffected': True} + detail = client.variation_detail('flag-normal', user, 'default') + assert detail.value == 'normal-value' + assert detail.reason == {'kind': 'OFF'} + + +def test_full_flag_override_evaluates_targeting_rules_and_referenced_override_segment(): + segment_flag = FlagBuilder('flag-segment').version(1).on(True).off_variation(0).fallthrough_variation(0).variations('not-included', 'included').rules( + FlagRuleBuilder().id('segment-rule').variation(1).clauses(make_clause_matching_segment_key('overridden-segment')).build() + ).build().to_json_dict() + ld_segment = SegmentBuilder('overridden-segment').version(100).build().to_json_dict() + override_segment = SegmentBuilder('overridden-segment').version(101).included(user.key).build().to_json_dict() + source = MockOverrideSource(flags={'flag-segment': segment_flag}, segments={'overridden-segment': override_segment}) + with make_initialized_client({}, source, segments={'overridden-segment': ld_segment}) as client: + detail = client.variation_detail('flag-segment', user, 'default') + assert detail.value == 'included' + assert detail.reason == {'kind': 'RULE_MATCH', 'ruleIndex': 0, 'ruleId': 'segment-rule', 'overrideAffected': True} + + +def test_overridden_prerequisite_marks_the_dependent_flag(): + dependent = FlagBuilder('dependent').version(1).on(True).off_variation(0).fallthrough_variation(1).variations('prereq-failed', 'affected-value').prerequisite('prereq', 1).build().to_json_dict() + ld_prereq = FlagBuilder('prereq').version(100).on(False).off_variation(0).variations('a', 'b').build().to_json_dict() + override_prereq = FlagBuilder('prereq').version(200).on(True).off_variation(0).fallthrough_variation(1).variations('a', 'b').build().to_json_dict() + source = MockOverrideSource(flags={'prereq': override_prereq}) + with make_initialized_client({'dependent': dependent, 'prereq': ld_prereq}, source) as client: + detail = client.variation_detail('dependent', user, 'default') + assert detail.value == 'affected-value' + assert detail.reason == {'kind': 'FALLTHROUGH', 'overrideAffected': True} + + +def test_all_flags_state_contains_only_overrides_when_client_is_not_initialized(): + source = MockOverrideSource(flags={'overridden-flag': single_value_flag('overridden-flag', True)}) + with make_uninitialized_client(source) as client: + state = client.all_flags_state(user) + assert state.valid is True + assert state.to_values_map() == {'overridden-flag': True} + + +def test_all_flags_state_overrides_only_warning_is_logged_once(caplog): + source = MockOverrideSource(flags={'overridden-flag': single_value_flag('overridden-flag', True)}) + with make_uninitialized_client(source) as client: + with caplog.at_level(logging.WARNING): + assert client.all_flags_state(user).valid is True + assert client.all_flags_state(user).valid is True + assert len(warnings_containing(caplog, 'Returning only flags from the override layer')) == 1 + + +def test_all_flags_state_is_invalid_when_not_initialized_and_override_layer_is_empty(): + source = MockOverrideSource() + with make_uninitialized_client(source) as client: + state = client.all_flags_state(user) + assert state.valid is False + assert state.to_values_map() == {} + + +def test_all_flags_state_reflects_overrides_when_initialized(): + ld_flag = FlagBuilder('flag-precedence').version(100).on(False).off_variation(0).variations('ld-value').build().to_json_dict() + normal = FlagBuilder('flag-normal').version(100).on(False).off_variation(0).variations('normal-value').build().to_json_dict() + source = MockOverrideSource(flags={ + 'flag-precedence': single_value_flag('flag-precedence', 'override-value'), + 'override-only': single_value_flag('override-only', 'only-value'), + }) + with make_initialized_client({'flag-precedence': ld_flag, 'flag-normal': normal}, source) as client: + state = client.all_flags_state(user, with_reasons=True) + assert state.valid is True + assert state.to_values_map() == {'flag-precedence': 'override-value', 'flag-normal': 'normal-value', 'override-only': 'only-value'} + assert state.get_flag_reason('flag-precedence') == {'kind': 'OFF', 'overrideAffected': True} + assert state.get_flag_reason('flag-normal') == {'kind': 'OFF'} + + +def test_flag_tracker_is_notified_of_override_changes(): + source = MockOverrideSource() + with make_uninitialized_client(source) as client: + changes: Queue = Queue() + client.flag_tracker.add_listener(lambda change: changes.put(change)) + + source.set_overrides({'overridden-flag': single_value_flag('overridden-flag', True)}, {}) + change = changes.get(timeout=5) + assert isinstance(change, FlagChange) + assert change.key == 'overridden-flag' + + # An identical snapshot notifies nothing. + source.set_overrides({'overridden-flag': single_value_flag('overridden-flag', True)}, {}) + with pytest.raises(Empty): + changes.get(timeout=0.3) + + source.set_overrides({}, {}) + assert changes.get(timeout=5).key == 'overridden-flag' + + +def test_flag_value_change_listener_sees_override_value_changes(): + ld_flag = FlagBuilder('flag').version(100).on(False).off_variation(0).variations('ld-value').build().to_json_dict() + source = MockOverrideSource() + with make_initialized_client({'flag': ld_flag}, source) as client: + changes: Queue = Queue() + client.flag_tracker.add_flag_value_change_listener('flag', user, lambda change: changes.put(change)) + + source.set_overrides({'flag': single_value_flag('flag', 'override-value')}, {}) + change = changes.get(timeout=5) + assert change.old_value == 'ld-value' + assert change.new_value == 'override-value' + + source.set_overrides({}, {}) + change = changes.get(timeout=5) + assert change.old_value == 'override-value' + assert change.new_value == 'ld-value' + + +def test_data_source_status_is_unaffected_by_overrides(): + source = MockOverrideSource(flags={'overridden-flag': single_value_flag('overridden-flag', True)}) + with make_uninitialized_client(source) as client: + assert client.is_initialized() is False + assert client.data_source_status_provider.status.state == DataSourceState.INITIALIZING diff --git a/ldclient/testing/testdata/override-vectors/vectors.json b/ldclient/testing/testdata/override-vectors/vectors.json new file mode 100644 index 00000000..48c3347c --- /dev/null +++ b/ldclient/testing/testdata/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 8043a6dfbaf5cea52e913ce0198c8208074ab96b Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Thu, 1 Oct 2026 23:09:05 +0000 Subject: [PATCH 2/4] test: Expect the fallthrough reason for value-only overrides --- ldclient/testing/test_async_client_overrides.py | 6 +++--- ldclient/testing/test_ldclient_overrides.py | 6 +++--- ldclient/testing/testdata/override-vectors/vectors.json | 4 ++-- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/ldclient/testing/test_async_client_overrides.py b/ldclient/testing/test_async_client_overrides.py index 7373e1db..d8e53bfb 100644 --- a/ldclient/testing/test_async_client_overrides.py +++ b/ldclient/testing/test_async_client_overrides.py @@ -79,7 +79,7 @@ async def test_override_is_served_when_client_is_not_initialized(): assert await client.is_initialized() is False detail = await client.variation_detail('overridden-flag', user, False) assert detail.value is True - assert detail.reason == {'kind': 'OFF', 'overrideAffected': True} + assert detail.reason == {'kind': 'FALLTHROUGH', 'overrideAffected': True} finally: await client.close() assert source.close_count == 1 @@ -127,13 +127,13 @@ async def test_override_takes_precedence_over_launchdarkly_data_and_all_flags_re try: detail = await client.variation_detail('flag-precedence', user, 'default') assert detail.value == 'override-value' - assert detail.reason == {'kind': 'OFF', 'overrideAffected': True} + assert detail.reason == {'kind': 'FALLTHROUGH', 'overrideAffected': True} detail = await client.variation_detail('flag-normal', user, 'default') assert detail.reason == {'kind': 'OFF'} state = await client.all_flags_state(user, with_reasons=True) assert state.to_values_map() == {'flag-precedence': 'override-value', 'flag-normal': 'normal-value'} - assert state.get_flag_reason('flag-precedence') == {'kind': 'OFF', 'overrideAffected': True} + assert state.get_flag_reason('flag-precedence') == {'kind': 'FALLTHROUGH', 'overrideAffected': True} finally: await client.close() diff --git a/ldclient/testing/test_ldclient_overrides.py b/ldclient/testing/test_ldclient_overrides.py index 030a603f..f6b54a44 100644 --- a/ldclient/testing/test_ldclient_overrides.py +++ b/ldclient/testing/test_ldclient_overrides.py @@ -63,7 +63,7 @@ def test_override_is_served_when_client_is_not_initialized(): detail = client.variation_detail('overridden-flag', user, False) assert detail.value is True assert detail.variation_index == 0 - assert detail.reason == {'kind': 'OFF', 'overrideAffected': True} + assert detail.reason == {'kind': 'FALLTHROUGH', 'overrideAffected': True} def test_non_overridden_flag_still_short_circuits_when_client_is_not_initialized(): @@ -140,7 +140,7 @@ def test_override_takes_precedence_over_launchdarkly_data(): with make_initialized_client({'flag-precedence': ld_flag, 'flag-normal': normal}, source) as client: detail = client.variation_detail('flag-precedence', user, 'default') assert detail.value == 'override-value' - assert detail.reason == {'kind': 'OFF', 'overrideAffected': True} + assert detail.reason == {'kind': 'FALLTHROUGH', 'overrideAffected': True} detail = client.variation_detail('flag-normal', user, 'default') assert detail.value == 'normal-value' assert detail.reason == {'kind': 'OFF'} @@ -206,7 +206,7 @@ def test_all_flags_state_reflects_overrides_when_initialized(): state = client.all_flags_state(user, with_reasons=True) assert state.valid is True assert state.to_values_map() == {'flag-precedence': 'override-value', 'flag-normal': 'normal-value', 'override-only': 'only-value'} - assert state.get_flag_reason('flag-precedence') == {'kind': 'OFF', 'overrideAffected': True} + assert state.get_flag_reason('flag-precedence') == {'kind': 'FALLTHROUGH', 'overrideAffected': True} assert state.get_flag_reason('flag-normal') == {'kind': 'OFF'} diff --git a/ldclient/testing/testdata/override-vectors/vectors.json b/ldclient/testing/testdata/override-vectors/vectors.json index 48c3347c..82dc62da 100644 --- a/ldclient/testing/testdata/override-vectors/vectors.json +++ b/ldclient/testing/testdata/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 f8ace1ae384ee744c2ae5d70dfe72f4f44d6c2fb Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Sat, 3 Oct 2026 00:42:40 +0000 Subject: [PATCH 3/4] fix: Ask the override layer for the key before the client has LaunchDarkly data Before initialization the clients consult the override layer directly. A flag the layer does not hold gets the not-ready handling it had without an override source, for an invalid context, a store outage, and an uninitialized persistent store alike. The all-flags state is unavailable when the layer is empty. The sync client stops the components it started when the data system fails to start. --- ldclient/async_client.py | 49 +++---- ldclient/client.py | 66 +++++---- ldclient/impl/datasystem/__init__.py | 17 +-- ldclient/impl/datasystem/async_fdv1.py | 4 +- ldclient/impl/datasystem/async_fdv2.py | 4 +- ldclient/impl/datasystem/fdv1.py | 4 +- ldclient/impl/datasystem/fdv2.py | 4 +- ldclient/testing/mock_components.py | 8 +- .../testing/test_async_client_overrides.py | 127 ++++++++++++++++- ldclient/testing/test_ldclient_overrides.py | 128 ++++++++++++++++-- 10 files changed, 334 insertions(+), 77 deletions(-) diff --git a/ldclient/async_client.py b/ldclient/async_client.py index d04f2ab6..d5191eca 100644 --- a/ldclient/async_client.py +++ b/ldclient/async_client.py @@ -505,36 +505,37 @@ def not_ready() -> Tuple[EvaluationDetail, Optional[FeatureFlag]]: self._send_event(event_factory.new_unknown_flag_event(key, context, default, reason)) return EvaluationDetail(default, None, reason), None - no_launchdarkly_data = False + flag: Any = None availability = await self._data_system.data_availability() if availability != DataAvailability.REFRESHED: if availability == DataAvailability.CACHED: if not self._eval_cached_data_warned: self._eval_cached_data_warned = True 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.") - elif self._data_system.override_source_configured: - # No data from LaunchDarkly is available. The store read below still finds an - # entry that the override layer holds, and the SDK serves it. A miss returns the - # not-ready default. - no_launchdarkly_data = True else: - return not_ready() + # No data from LaunchDarkly is available. A flag that the override layer holds + # is served anyway. A flag the layer does not hold gets the not-ready default, + # as it does without an override source. + layer = self._data_system.override_layer + if layer is not None: + flag = layer.get(FEATURES, key) + if flag is None: + return not_ready() if not context.valid: log.warning("Context was invalid for flag evaluation (%s); returning default value" % context.error) return EvaluationDetail(default, None, error_reason('USER_NOT_SPECIFIED')), None - try: - flag = await self._data_system.store.get(FEATURES, key) - except Exception as e: - log.error("Unexpected error while retrieving feature flag \"%s\": %s" % (key, repr(e))) - log.debug(traceback.format_exc()) - reason = error_reason('EXCEPTION') - self._send_event(event_factory.new_unknown_flag_event(key, context, default, reason)) - return EvaluationDetail(default, None, reason), None + if flag is None: + try: + flag = await self._data_system.store.get(FEATURES, key) + except Exception as e: + log.error("Unexpected error while retrieving feature flag \"%s\": %s" % (key, repr(e))) + log.debug(traceback.format_exc()) + reason = error_reason('EXCEPTION') + self._send_event(event_factory.new_unknown_flag_event(key, context, default, reason)) + return EvaluationDetail(default, None, reason), None if not flag: - if no_launchdarkly_data: - return not_ready() reason = error_reason('FLAG_NOT_FOUND') self._send_event(event_factory.new_unknown_flag_event(key, context, default, reason)) return EvaluationDetail(default, None, reason), None @@ -591,13 +592,15 @@ async def all_flags_state(self, context: Context, **kwargs) -> FeatureFlagsState if not self._all_flags_cached_data_warned: self._all_flags_cached_data_warned = True log.warning("all_flags_state() called before client has finished initializing! Using last known values from feature store. This message is logged once.") - elif self._data_system.override_source_configured: - # No data from LaunchDarkly is available. The store read below returns only the - # entries that the override layer holds. The result decides the state. - overrides_only = True else: - log.warning("all_flags_state() called before client has finished initializing! Feature store unavailable - returning empty state") - return FeatureFlagsState(False) + # No data from LaunchDarkly is available. When the override layer holds entries, + # the state is built from them alone. Otherwise the state is unavailable, as it + # is without an override source. + layer = self._data_system.override_layer + if layer is None or layer.is_empty: + log.warning("all_flags_state() called before client has finished initializing! Feature store unavailable - returning empty state") + return FeatureFlagsState(False) + overrides_only = True if not context.valid: log.warning("Context was invalid for all_flags_state (%s); returning default value" % context.error) diff --git a/ldclient/client.py b/ldclient/client.py index f6067723..c03de402 100644 --- a/ldclient/client.py +++ b/ldclient/client.py @@ -212,7 +212,13 @@ def __start_up(self, start_wait: float): self.__register_plugins(environment_metadata) update_processor_ready = threading.Event() - self._data_system.start(update_processor_ready) + try: + self._data_system.start(update_processor_ready) + except BaseException: + # A data system that fails to start fails the constructor. The components that + # are already running stop here, so the failure leaves no threads behind. + self._close_components() + raise if not self._config.offline and not self._config.use_ldd: if start_wait > 60: @@ -282,6 +288,15 @@ def close(self): self._data_system.stop() self.__big_segment_store_manager.stop() + def _close_components(self): + """ + Stops the SDK components after a failed start-up. The event processor is last because + it may still be sending events the other components generated. + """ + self._data_system.stop() + self.__big_segment_store_manager.stop() + self._event_processor.stop() + # These magic methods allow a client object to be automatically cleaned up by the "with" scope operator def __enter__(self): return self @@ -474,7 +489,7 @@ def not_ready() -> Tuple[EvaluationDetail, Optional[FeatureFlag]]: self._send_event(event_factory.new_unknown_flag_event(key, context, default, reason)) return EvaluationDetail(default, None, reason), None - no_launchdarkly_data = False + flag: Any = None availability = self._data_system.data_availability if availability != DataAvailability.REFRESHED: if availability == DataAvailability.CACHED: @@ -483,29 +498,30 @@ def not_ready() -> Tuple[EvaluationDetail, Optional[FeatureFlag]]: if not self._eval_cached_data_warned: self._eval_cached_data_warned = True 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.") - elif self._data_system.override_source_configured: - # No data from LaunchDarkly is available. The store read below still finds an - # entry that the override layer holds, and the SDK serves it. A miss returns the - # not-ready default. - no_launchdarkly_data = True else: - return not_ready() + # No data from LaunchDarkly is available. A flag that the override layer holds + # is served anyway. A flag the layer does not hold gets the not-ready default, + # as it does without an override source. + layer = self._data_system.override_layer + if layer is not None: + flag = layer.get(FEATURES, key) + if flag is None: + return not_ready() if not context.valid: log.warning("Context was invalid for flag evaluation (%s); returning default value" % context.error) return EvaluationDetail(default, None, error_reason('USER_NOT_SPECIFIED')), None - try: - flag = self._data_system.store.get(FEATURES, key) - except Exception as e: - log.error("Unexpected error while retrieving feature flag \"%s\": %s" % (key, repr(e))) - log.debug(traceback.format_exc()) - reason = error_reason('EXCEPTION') - self._send_event(event_factory.new_unknown_flag_event(key, context, default, reason)) - return EvaluationDetail(default, None, reason), None + if flag is None: + try: + flag = self._data_system.store.get(FEATURES, key) + except Exception as e: + log.error("Unexpected error while retrieving feature flag \"%s\": %s" % (key, repr(e))) + log.debug(traceback.format_exc()) + reason = error_reason('EXCEPTION') + self._send_event(event_factory.new_unknown_flag_event(key, context, default, reason)) + return EvaluationDetail(default, None, reason), None if not flag: - if no_launchdarkly_data: - return not_ready() reason = error_reason('FLAG_NOT_FOUND') self._send_event(event_factory.new_unknown_flag_event(key, context, default, reason)) return EvaluationDetail(default, None, reason), None @@ -566,13 +582,15 @@ def all_flags_state(self, context: Context, **kwargs) -> FeatureFlagsState: if not self._all_flags_cached_data_warned: self._all_flags_cached_data_warned = True log.warning("all_flags_state() called before client has finished initializing! Using last known values from feature store. This message is logged once.") - elif self._data_system.override_source_configured: - # No data from LaunchDarkly is available. The store read below returns only the - # entries that the override layer holds. The result decides the state. - overrides_only = True else: - log.warning("all_flags_state() called before client has finished initializing! Feature store unavailable - returning empty state") - return FeatureFlagsState(False) + # No data from LaunchDarkly is available. When the override layer holds entries, + # the state is built from them alone. Otherwise the state is unavailable, as it + # is without an override source. + layer = self._data_system.override_layer + if layer is None or layer.is_empty: + log.warning("all_flags_state() called before client has finished initializing! Feature store unavailable - returning empty state") + return FeatureFlagsState(False) + overrides_only = True if not context.valid: log.warning("Context was invalid for all_flags_state (%s); returning default value" % context.error) diff --git a/ldclient/impl/datasystem/__init__.py b/ldclient/impl/datasystem/__init__.py index 88f1f7b7..6799351f 100644 --- a/ldclient/impl/datasystem/__init__.py +++ b/ldclient/impl/datasystem/__init__.py @@ -10,6 +10,7 @@ if TYPE_CHECKING: from ldclient.impl.aio.concurrency import AsyncEvent + from ldclient.impl.overrides.layer import OverrideLayer from ldclient.impl.listeners import Listeners from ldclient.interfaces import ( @@ -160,12 +161,12 @@ def environment_id(self) -> Optional[str]: @property @abstractmethod - def override_source_configured(self) -> bool: + def override_layer(self) -> Optional["OverrideLayer"]: """ - Reports whether the data system was built with an override source. The value is - fixed at construction. When true, the store served by :attr:`store` overlays the - override layer, and the client serves an overridden flag before it has LaunchDarkly - data. + Returns the override layer when the data system was built with an override source, + and None otherwise. The store served by :attr:`store` overlays this layer. The client + consults the layer directly before it has LaunchDarkly data, so that a flag the layer + holds is served and a flag it does not hold gets the not-ready handling. """ raise NotImplementedError @@ -245,10 +246,10 @@ def store(self) -> AsyncReadOnlyStore: @property @abstractmethod - def override_source_configured(self) -> bool: + def override_layer(self) -> Optional["OverrideLayer"]: """ - Reports whether the data system was built with an override source. The value is - fixed at construction. + Returns the override layer when the data system was built with an override source, + and None otherwise. See :attr:`DataSystem.override_layer`. """ raise NotImplementedError diff --git a/ldclient/impl/datasystem/async_fdv1.py b/ldclient/impl/datasystem/async_fdv1.py index b8b762f1..a7bd1455 100644 --- a/ldclient/impl/datasystem/async_fdv1.py +++ b/ldclient/impl/datasystem/async_fdv1.py @@ -125,9 +125,9 @@ def store(self) -> AsyncReadOnlyStore: return self._store_view @property - def override_source_configured(self) -> bool: + def override_layer(self) -> None: # Overrides are an option of the FDv2 data system only. - return False + return None def set_diagnostic_accumulator(self, diagnostic_accumulator: DiagnosticAccumulator): """ diff --git a/ldclient/impl/datasystem/async_fdv2.py b/ldclient/impl/datasystem/async_fdv2.py index 9f6d93db..22dd735a 100644 --- a/ldclient/impl/datasystem/async_fdv2.py +++ b/ldclient/impl/datasystem/async_fdv2.py @@ -677,8 +677,8 @@ def store(self) -> AsyncReadOnlyStore: return self._store_view @property - def override_source_configured(self) -> bool: - return self._override_source is not None + def override_layer(self) -> Optional[OverrideLayer]: + return self._override_layer async def data_availability(self) -> DataAvailability: # type: ignore[override] """Reports what form of data is currently available, awaiting the store's diff --git a/ldclient/impl/datasystem/fdv1.py b/ldclient/impl/datasystem/fdv1.py index dabaa12d..9d0008a1 100644 --- a/ldclient/impl/datasystem/fdv1.py +++ b/ldclient/impl/datasystem/fdv1.py @@ -234,9 +234,9 @@ def store(self) -> ReadOnlyStore: return self._store_view @property - def override_source_configured(self) -> bool: + def override_layer(self) -> None: # Overrides are an option of the FDv2 data system only. - return False + return None @property def environment_id(self) -> Optional[str]: diff --git a/ldclient/impl/datasystem/fdv2.py b/ldclient/impl/datasystem/fdv2.py index 4d5398fb..b218bc3f 100644 --- a/ldclient/impl/datasystem/fdv2.py +++ b/ldclient/impl/datasystem/fdv2.py @@ -680,8 +680,8 @@ def store(self) -> ReadOnlyStore: return self._store_view @property - def override_source_configured(self) -> bool: - return self._override_source is not None + def override_layer(self) -> Optional[OverrideLayer]: + return self._override_layer __all__ = [ diff --git a/ldclient/testing/mock_components.py b/ldclient/testing/mock_components.py index 59c257e1..f8c2e85e 100644 --- a/ldclient/testing/mock_components.py +++ b/ldclient/testing/mock_components.py @@ -69,15 +69,21 @@ class MockOverrideSource: on every later call to set_overrides, and records its lifecycle calls. """ - def __init__(self, flags: Optional[Dict[str, Any]] = None, segments: Optional[Dict[str, Any]] = None): + def __init__(self, flags: Optional[Dict[str, Any]] = None, segments: Optional[Dict[str, Any]] = None, start_error: Optional[Exception] = None): + """ + :param start_error: when given, start raises it instead of supplying the contents + """ self._flags: Dict[str, Any] = dict(flags or {}) self._segments: Dict[str, Any] = dict(segments or {}) + self._start_error = start_error self._sink: Optional[OverrideSink] = None self.start_count = 0 self.close_count = 0 def start(self, sink: OverrideSink) -> None: self.start_count += 1 + if self._start_error is not None: + raise self._start_error self._sink = sink sink.set_overrides(self._flags, self._segments) diff --git a/ldclient/testing/test_async_client_overrides.py b/ldclient/testing/test_async_client_overrides.py index d8e53bfb..bce576e0 100644 --- a/ldclient/testing/test_async_client_overrides.py +++ b/ldclient/testing/test_async_client_overrides.py @@ -4,15 +4,18 @@ """ import asyncio import threading -from typing import Any, Dict, Optional +from typing import Any, Dict, List, Optional import pytest from ldclient.async_client import AsyncLDClient from ldclient.async_config import AsyncConfig, AsyncDataSystemConfig +from ldclient.async_feature_store import AsyncInMemoryFeatureStore from ldclient.context import Context +from ldclient.evaluation import EvaluationDetail from ldclient.impl.aio.concurrency import AsyncEvent from ldclient.impl.integrations.files.filedata import make_flag_with_value +from ldclient.interfaces import AsyncFeatureStore, DataStoreMode from ldclient.testing.builders import FlagBuilder from ldclient.testing.mock_async_components import MockAsyncEventProcessor from ldclient.testing.mock_components import ( @@ -21,9 +24,13 @@ MockOverrideSource, StaticInitializer ) +from ldclient.versioned_data_kind import FEATURES user = Context.create('user-key') +# A context whose key is empty is invalid. +invalid_context = Context.create('') + class AsyncHangingSynchronizer: """An async synchronizer that connects but never yields data.""" @@ -49,12 +56,52 @@ async def fetch(self, ss): # type: ignore[override] return super().fetch(ss) +class UnavailableAsyncStore(AsyncInMemoryFeatureStore): + """A persistent store whose every read fails, as during a store outage.""" + + async def is_initialized(self) -> bool: + raise RuntimeError("store unreachable") + + async def get(self, kind, key): + raise RuntimeError("store unreachable") + + async def all(self, kind): + raise RuntimeError("store unreachable") + + +class UninitializedAsyncStore(AsyncInMemoryFeatureStore): + """A persistent store that holds data but was never initialized by an SDK.""" + + @property + def initialized(self) -> bool: + return False + + async def is_initialized(self) -> bool: + return False + + +async def uninitialized_store_holding(key: str) -> UninitializedAsyncStore: + store = UninitializedAsyncStore() + await store.upsert(FEATURES, FlagBuilder(key).version(1).on(False).off_variation(0).variations('ld-value').build().to_json_dict()) + return store + + def single_value_flag(key: str, value: Any) -> dict: return make_flag_with_value(key, value).to_json_dict() -async def make_uninitialized_client(source: MockOverrideSource) -> AsyncLDClient: - datasystem = AsyncDataSystemConfig(synchronizers=[MockDataSourceBuilder(AsyncHangingSynchronizer())], override_source=source.builder) +def recorded_keys(client: AsyncLDClient) -> List[str]: + """The flag keys of the evaluations the client handed to the event processor.""" + processor: Any = client._event_processor + return [event.key for event in processor.events] + + +async def make_uninitialized_client(source: MockOverrideSource, store: Optional[AsyncFeatureStore] = None) -> AsyncLDClient: + """ + A client whose data system can never obtain LaunchDarkly data, with the given override + source and, when given, a read-only persistent store. + """ + datasystem = AsyncDataSystemConfig(synchronizers=[MockDataSourceBuilder(AsyncHangingSynchronizer())], override_source=source.builder, data_store=store, data_store_mode=DataStoreMode.READ_ONLY) config = AsyncConfig('SDK_KEY', datasystem_config=datasystem, event_processor_class=lambda config: MockAsyncEventProcessor()) client = AsyncLDClient(config) await client.start(start_wait=0) @@ -110,6 +157,80 @@ async def test_override_removal_restores_short_circuit(): await client.close() +@pytest.mark.asyncio +@pytest.mark.parametrize('overrides', [{}, {'other-flag': single_value_flag('other-flag', True)}], ids=['empty-layer', 'layer-without-the-key']) +async def test_not_initialized_client_returns_not_ready_for_an_invalid_context_when_the_layer_lacks_the_key(overrides): + client = await make_uninitialized_client(MockOverrideSource(flags=overrides)) + try: + detail = await client.variation_detail('requested-flag', invalid_context, False) + # The not-ready handling runs before the context check, as it does without overrides, + # and records the evaluation of the unknown flag. + assert detail == EvaluationDetail(False, None, {'kind': 'ERROR', 'errorKind': 'CLIENT_NOT_READY'}) + assert recorded_keys(client) == ['requested-flag'] + finally: + await client.close() + + +@pytest.mark.asyncio +async def test_not_initialized_client_checks_the_context_before_serving_an_override(): + source = MockOverrideSource(flags={'overridden-flag': single_value_flag('overridden-flag', True)}) + client = await make_uninitialized_client(source) + try: + detail = await client.variation_detail('overridden-flag', invalid_context, False) + assert detail == EvaluationDetail(False, None, {'kind': 'ERROR', 'errorKind': 'USER_NOT_SPECIFIED'}) + assert recorded_keys(client) == [] + finally: + await client.close() + + +@pytest.mark.asyncio +@pytest.mark.parametrize('store_outage', [True, False], ids=['store-outage', 'uninitialized-store']) +async def test_not_initialized_client_returns_not_ready_when_the_store_has_no_launchdarkly_data_and_the_layer_lacks_the_key(store_outage): + store: AsyncFeatureStore = UnavailableAsyncStore() if store_outage else await uninitialized_store_holding('requested-flag') + source = MockOverrideSource(flags={'other-flag': single_value_flag('other-flag', True)}) + client = await make_uninitialized_client(source, store=store) + try: + detail = await client.variation_detail('requested-flag', user, False) + assert detail == EvaluationDetail(False, None, {'kind': 'ERROR', 'errorKind': 'CLIENT_NOT_READY'}) + assert recorded_keys(client) == ['requested-flag'] + finally: + await client.close() + + +@pytest.mark.asyncio +async def test_override_is_served_during_a_persistent_store_outage(): + source = MockOverrideSource(flags={'overridden-flag': single_value_flag('overridden-flag', True)}) + client = await make_uninitialized_client(source, store=UnavailableAsyncStore()) + try: + detail = await client.variation_detail('overridden-flag', user, False) + assert detail.value is True + assert detail.reason == {'kind': 'FALLTHROUGH', 'overrideAffected': True} + finally: + await client.close() + + +@pytest.mark.asyncio +async def test_all_flags_state_is_invalid_when_the_store_is_uninitialized_and_the_layer_is_empty(): + client = await make_uninitialized_client(MockOverrideSource(), store=await uninitialized_store_holding('ld-flag')) + try: + state = await client.all_flags_state(user) + assert state.valid is False + assert state.to_values_map() == {} + finally: + await client.close() + + +@pytest.mark.asyncio +async def test_override_source_start_failure_fails_start_and_closes_the_source(): + source = MockOverrideSource(start_error=RuntimeError("cannot start")) + datasystem = AsyncDataSystemConfig(synchronizers=[MockDataSourceBuilder(AsyncHangingSynchronizer())], override_source=source.builder) + config = AsyncConfig('SDK_KEY', datasystem_config=datasystem, event_processor_class=lambda config: MockAsyncEventProcessor()) + client = AsyncLDClient(config) + with pytest.raises(RuntimeError): + await client.start(start_wait=0) + assert source.close_count == 1 + + @pytest.mark.asyncio async def test_invalid_override_source_configuration_fails_construction(): datasystem = AsyncDataSystemConfig(synchronizers=[MockDataSourceBuilder(AsyncHangingSynchronizer())], override_source=FailingOverrideSourceBuilder()) diff --git a/ldclient/testing/test_ldclient_overrides.py b/ldclient/testing/test_ldclient_overrides.py index f6b54a44..fea18891 100644 --- a/ldclient/testing/test_ldclient_overrides.py +++ b/ldclient/testing/test_ldclient_overrides.py @@ -4,16 +4,22 @@ """ import logging from queue import Empty, Queue -from typing import Any, Dict, Optional +from typing import Any, Dict, List, Optional import pytest from ldclient.client import Config, Context, LDClient from ldclient.datasystem import custom from ldclient.evaluation import EvaluationDetail +from ldclient.feature_store import InMemoryFeatureStore from ldclient.impl.datasystem.fdv1 import FDv1 from ldclient.impl.integrations.files.filedata import make_flag_with_value -from ldclient.interfaces import DataSourceState, FlagChange +from ldclient.interfaces import ( + DataSourceState, + DataStoreMode, + FeatureStore, + FlagChange +) from ldclient.testing.builders import ( FlagBuilder, FlagRuleBuilder, @@ -27,18 +33,27 @@ StaticInitializer ) from ldclient.testing.stub_util import MockEventProcessor +from ldclient.versioned_data_kind import FEATURES user = Context.create('user-key') +# A context whose key is empty is invalid. +invalid_context = Context.create('') + def single_value_flag(key: str, value: Any) -> dict: return make_flag_with_value(key, value).to_json_dict() -def make_uninitialized_client(source: MockOverrideSource) -> LDClient: - """A client whose data system can never obtain LaunchDarkly data, with the given override source.""" - datasystem = custom().synchronizers(HangingSynchronizer().builder).overrides(source.builder).build() - config = Config(sdk_key='SDK_KEY', datasystem_config=datasystem, event_processor_class=MockEventProcessor) +def make_uninitialized_client(source: MockOverrideSource, store: Optional[FeatureStore] = None) -> LDClient: + """ + A client whose data system can never obtain LaunchDarkly data, with the given override + source and, when given, a read-only persistent store. + """ + datasystem = custom().synchronizers(HangingSynchronizer().builder).overrides(source.builder) + if store is not None: + datasystem.data_store(store, DataStoreMode.READ_ONLY) + config = Config(sdk_key='SDK_KEY', datasystem_config=datasystem.build(), event_processor_class=MockEventProcessor) return LDClient(config, start_wait=0) @@ -56,6 +71,40 @@ def warnings_containing(caplog, text: str): return [r for r in caplog.records if r.levelno == logging.WARNING and text in r.getMessage()] +def recorded_keys(client: LDClient) -> List[str]: + """The flag keys of the evaluations the client handed to the event processor.""" + processor: Any = client._event_processor + return [event.key for event in processor._events] + + +class UnavailableStore(InMemoryFeatureStore): + """A persistent store whose every read fails, as during a store outage.""" + + @property + def initialized(self) -> bool: + raise RuntimeError("store unreachable") + + def get(self, kind, key, callback=lambda x: x): + raise RuntimeError("store unreachable") + + def all(self, kind, callback=lambda x: x): + raise RuntimeError("store unreachable") + + +class UninitializedStore(InMemoryFeatureStore): + """A persistent store that holds data but was never initialized by an SDK.""" + + @property + def initialized(self) -> bool: + return False + + +def uninitialized_store_holding(key: str) -> UninitializedStore: + store = UninitializedStore() + store.upsert(FEATURES, FlagBuilder(key).version(1).on(False).off_variation(0).variations('ld-value').build().to_json_dict()) + return store + + def test_override_is_served_when_client_is_not_initialized(): source = MockOverrideSource(flags={'overridden-flag': single_value_flag('overridden-flag', True)}) with make_uninitialized_client(source) as client: @@ -83,6 +132,48 @@ def test_override_removal_restores_short_circuit(): assert detail.reason == {'kind': 'ERROR', 'errorKind': 'CLIENT_NOT_READY'} +@pytest.mark.parametrize('overrides', [{}, {'other-flag': single_value_flag('other-flag', True)}], ids=['empty-layer', 'layer-without-the-key']) +def test_not_initialized_client_returns_not_ready_for_an_invalid_context_when_the_layer_lacks_the_key(overrides): + with make_uninitialized_client(MockOverrideSource(flags=overrides)) as client: + detail = client.variation_detail('requested-flag', invalid_context, False) + # The not-ready handling runs before the context check, as it does without overrides, + # and records the evaluation of the unknown flag. + assert detail == EvaluationDetail(False, None, {'kind': 'ERROR', 'errorKind': 'CLIENT_NOT_READY'}) + assert recorded_keys(client) == ['requested-flag'] + + +def test_not_initialized_client_checks_the_context_before_serving_an_override(): + source = MockOverrideSource(flags={'overridden-flag': single_value_flag('overridden-flag', True)}) + with make_uninitialized_client(source) as client: + detail = client.variation_detail('overridden-flag', invalid_context, False) + assert detail == EvaluationDetail(False, None, {'kind': 'ERROR', 'errorKind': 'USER_NOT_SPECIFIED'}) + assert recorded_keys(client) == [] + + +@pytest.mark.parametrize('make_store', [UnavailableStore, lambda: uninitialized_store_holding('requested-flag')], ids=['store-outage', 'uninitialized-store']) +def test_not_initialized_client_returns_not_ready_when_the_store_has_no_launchdarkly_data_and_the_layer_lacks_the_key(make_store): + source = MockOverrideSource(flags={'other-flag': single_value_flag('other-flag', True)}) + with make_uninitialized_client(source, store=make_store()) as client: + detail = client.variation_detail('requested-flag', user, False) + assert detail == EvaluationDetail(False, None, {'kind': 'ERROR', 'errorKind': 'CLIENT_NOT_READY'}) + assert recorded_keys(client) == ['requested-flag'] + + +def test_override_is_served_during_a_persistent_store_outage(): + source = MockOverrideSource(flags={'overridden-flag': single_value_flag('overridden-flag', True)}) + with make_uninitialized_client(source, store=UnavailableStore()) as client: + detail = client.variation_detail('overridden-flag', user, False) + assert detail.value is True + assert detail.reason == {'kind': 'FALLTHROUGH', 'overrideAffected': True} + + +def test_all_flags_state_is_invalid_when_the_store_is_uninitialized_and_the_layer_is_empty(): + with make_uninitialized_client(MockOverrideSource(), store=uninitialized_store_holding('ld-flag')) as client: + state = client.all_flags_state(user) + assert state.valid is False + assert state.to_values_map() == {} + + def test_override_source_is_started_before_the_constructor_returns(): source = MockOverrideSource(flags={'overridden-flag': single_value_flag('overridden-flag', True)}) client = make_uninitialized_client(source) @@ -108,6 +199,23 @@ def test_invalid_override_source_configuration_fails_construction(): LDClient(config, start_wait=0) +def test_override_source_start_failure_fails_construction_and_stops_the_started_components(): + stopped = [] + + class RecordingEventProcessor(MockEventProcessor): + def stop(self): + stopped.append(True) + + source = MockOverrideSource(start_error=RuntimeError("cannot start")) + datasystem = custom().synchronizers(HangingSynchronizer().builder).overrides(source.builder).build() + config = Config(sdk_key='SDK_KEY', datasystem_config=datasystem, event_processor_class=RecordingEventProcessor) + with pytest.raises(RuntimeError): + LDClient(config, start_wait=0) + # The source and the event processor that were set up before the failure are closed. + assert source.close_count == 1 + assert stopped == [True] + + def test_override_source_is_not_started_when_offline(): source = MockOverrideSource(flags={'overridden-flag': single_value_flag('overridden-flag', True)}) datasystem = custom().overrides(source.builder).build() @@ -117,20 +225,20 @@ def test_override_source_is_not_started_when_offline(): assert client.variation('overridden-flag', user, False) is False -def test_data_system_without_override_source_reports_none_configured(): +def test_data_system_without_override_source_has_no_override_layer(): datasystem = custom().synchronizers(HangingSynchronizer().builder).build() config = Config(sdk_key='SDK_KEY', datasystem_config=datasystem, event_processor_class=MockEventProcessor) with LDClient(config, start_wait=0) as client: - assert client._data_system.override_source_configured is False + assert client._data_system.override_layer is None detail = client.variation_detail('any-flag', user, False) assert detail.reason == {'kind': 'ERROR', 'errorKind': 'CLIENT_NOT_READY'} -def test_legacy_data_system_reports_no_override_source(): +def test_legacy_data_system_has_no_override_layer(): config = Config(sdk_key='SDK_KEY', offline=True) with LDClient(config) as client: assert isinstance(client._data_system, FDv1) - assert client._data_system.override_source_configured is False + assert client._data_system.override_layer is None def test_override_takes_precedence_over_launchdarkly_data(): From 1c1bd5c57bcd4ce2c12f2f459c276552dc78aa30 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Sat, 3 Oct 2026 01:16:43 +0000 Subject: [PATCH 4/4] docs: Describe what the all flags state holds before the client has LaunchDarkly data --- ldclient/async_client.py | 5 +++-- ldclient/client.py | 5 +++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/ldclient/async_client.py b/ldclient/async_client.py index d5191eca..6f92a086 100644 --- a/ldclient/async_client.py +++ b/ldclient/async_client.py @@ -594,8 +594,9 @@ async def all_flags_state(self, context: Context, **kwargs) -> FeatureFlagsState log.warning("all_flags_state() called before client has finished initializing! Using last known values from feature store. This message is logged once.") else: # No data from LaunchDarkly is available. When the override layer holds entries, - # the state is built from them alone. Otherwise the state is unavailable, as it - # is without an override source. + # the state is read through the overlay, which returns the override entries and + # any data the store already holds. Otherwise the state is unavailable, as it is + # without an override source. layer = self._data_system.override_layer if layer is None or layer.is_empty: log.warning("all_flags_state() called before client has finished initializing! Feature store unavailable - returning empty state") diff --git a/ldclient/client.py b/ldclient/client.py index c03de402..8d0996d0 100644 --- a/ldclient/client.py +++ b/ldclient/client.py @@ -584,8 +584,9 @@ def all_flags_state(self, context: Context, **kwargs) -> FeatureFlagsState: log.warning("all_flags_state() called before client has finished initializing! Using last known values from feature store. This message is logged once.") else: # No data from LaunchDarkly is available. When the override layer holds entries, - # the state is built from them alone. Otherwise the state is unavailable, as it - # is without an override source. + # the state is read through the overlay, which returns the override entries and + # any data the store already holds. Otherwise the state is unavailable, as it is + # without an override source. layer = self._data_system.override_layer if layer is None or layer.is_empty: log.warning("all_flags_state() called before client has finished initializing! Feature store unavailable - returning empty state")