From fd78ef9beabec736eda5b5f976d68b6bf21a88c8 Mon Sep 17 00:00:00 2001 From: "Matthew M. Keeler" Date: Thu, 30 Oct 2025 15:40:49 -0400 Subject: [PATCH 01/13] chore: Initial structure and implementation of FDv2 datasystem (#356) --- ldclient/client.py | 29 +- ldclient/config.py | 42 +- ldclient/impl/datasourcev2/polling.py | 1 + ldclient/impl/datasourcev2/status.py | 57 +++ ldclient/impl/datasourcev2/streaming.py | 7 + ldclient/impl/datastore/status.py | 2 +- ldclient/impl/datasystem/__init__.py | 15 + ldclient/impl/datasystem/config.py | 26 +- ldclient/impl/datasystem/fdv1.py | 11 +- ldclient/impl/datasystem/fdv2.py | 415 ++++++++++++++++++ ldclient/impl/datasystem/store.py | 355 +++++++++++++++ .../test_datav2/test_data_sourcev2.py | 5 + .../datasourcev2/test_polling_initializer.py | 2 +- .../datasourcev2/test_polling_synchronizer.py | 80 ---- .../testing/impl/datasystem/test_config.py | 11 +- .../impl/datasystem/test_fdv2_datasystem.py | 159 +++++++ 16 files changed, 1093 insertions(+), 124 deletions(-) create mode 100644 ldclient/impl/datasourcev2/status.py create mode 100644 ldclient/impl/datasystem/fdv2.py create mode 100644 ldclient/impl/datasystem/store.py create mode 100644 ldclient/testing/impl/datasystem/test_fdv2_datasystem.py diff --git a/ldclient/client.py b/ldclient/client.py index 091b064f..6c3269ad 100644 --- a/ldclient/client.py +++ b/ldclient/client.py @@ -30,6 +30,8 @@ DataStoreStatusProviderImpl, DataStoreUpdateSinkImpl ) +from ldclient.impl.datasystem import DataAvailability, DataSystem +from ldclient.impl.datasystem.fdv2 import FDv2 from ldclient.impl.evaluator import Evaluator, error_reason from ldclient.impl.events.diagnostics import ( _DiagnosticAccumulator, @@ -249,14 +251,19 @@ def __start_up(self, start_wait: float): self.__hooks_lock = ReadWriteLock() self.__hooks = self._config.hooks + plugin_hooks # type: List[Hook] - # Initialize data system (FDv1) to encapsulate v1 data plumbing - from ldclient.impl.datasystem.fdv1 import ( # local import to avoid circular dependency - FDv1 - ) + datasystem_config = self._config.datasystem_config + if datasystem_config is None: + # Initialize data system (FDv1) to encapsulate v1 data plumbing + from ldclient.impl.datasystem.fdv1 import ( # local import to avoid circular dependency + FDv1 + ) + + self._data_system: DataSystem = FDv1(self._config) + else: + self._data_system = FDv2(datasystem_config, disabled=self._config.offline) - self._data_system = FDv1(self._config) # Provide flag evaluation function for value-change tracking - self._data_system.set_flag_value_eval_fn( + self._data_system.set_flag_value_eval_fn( # type: ignore lambda key, context: self.variation(key, context, None) ) # Expose providers and store from data system @@ -265,7 +272,7 @@ def __start_up(self, start_wait: float): self._data_system.data_source_status_provider ) self.__flag_tracker = self._data_system.flag_tracker - self._store = self._data_system.store # type: FeatureStore + self._store: FeatureStore = self._data_system.store # type: ignore big_segment_store_manager = BigSegmentStoreManager(self._config.big_segments) self.__big_segment_store_manager = big_segment_store_manager @@ -286,7 +293,7 @@ def __start_up(self, start_wait: float): diagnostic_accumulator = self._set_event_processor(self._config) # Pass diagnostic accumulator to data system for streaming metrics - self._data_system.set_diagnostic_accumulator(diagnostic_accumulator) + self._data_system.set_diagnostic_accumulator(diagnostic_accumulator) # type: ignore self.__register_plugins(environment_metadata) @@ -475,11 +482,7 @@ def is_initialized(self) -> bool: if self.is_offline() or self._config.use_ldd: return True - return ( - self._data_system._update_processor.initialized() - if self._data_system._update_processor - else False - ) + return self._data_system.data_availability.at_least(DataAvailability.CACHED) def flush(self): """Flushes all pending analytics events. diff --git a/ldclient/config.py b/ldclient/config.py index fbc88ac8..af5e62b7 100644 --- a/ldclient/config.py +++ b/ldclient/config.py @@ -4,11 +4,13 @@ Note that the same class can also be imported from the ``ldclient.client`` submodule. """ +from dataclasses import dataclass from threading import Event -from typing import Callable, List, Optional, Set +from typing import Callable, List, Optional, Set, TypeVar from ldclient.feature_store import InMemoryFeatureStore from ldclient.hook import Hook +from ldclient.impl.datasystem import Initializer, Synchronizer from ldclient.impl.util import ( log, validate_application_info, @@ -152,6 +154,32 @@ def disable_ssl_verification(self) -> bool: return self.__disable_ssl_verification +T = TypeVar("T") + +Builder = Callable[[], T] + + +@dataclass(frozen=True) +class DataSystemConfig: + """ + Configuration for LaunchDarkly's data acquisition strategy. + """ + + initializers: Optional[List[Builder[Initializer]]] + """The initializers for the data system.""" + + primary_synchronizer: Builder[Synchronizer] + """The primary synchronizer for the data system.""" + + secondary_synchronizer: Optional[Builder[Synchronizer]] = None + """The secondary synchronizers for the data system.""" + + # TODO(fdv2): Implement this synchronizer up and hook it up everywhere. + # TODO(fdv2): Remove this when FDv2 is fully launched + fdv1_fallback_synchronizer: Optional[Builder[Synchronizer]] = None + """An optional fallback synchronizer that will read from FDv1""" + + class Config: """Advanced configuration options for the SDK client. @@ -194,6 +222,7 @@ def __init__( enable_event_compression: bool = False, omit_anonymous_contexts: bool = False, payload_filter_key: Optional[str] = None, + datasystem_config: Optional[DataSystemConfig] = None, ): """ :param sdk_key: The SDK key for your LaunchDarkly account. This is always required. @@ -264,6 +293,7 @@ def __init__( :param enable_event_compression: Whether or not to enable GZIP compression for outgoing events. :param omit_anonymous_contexts: Sets whether anonymous contexts should be omitted from index and identify events. :param payload_filter_key: The payload filter is used to selectively limited the flags and segments delivered in the data source payload. + :param datasystem_config: Configuration for the upcoming enhanced data system design. This is experimental and should not be set without direction from LaunchDarkly support. """ self.__sdk_key = validate_sdk_key_format(sdk_key, log) @@ -303,6 +333,7 @@ def __init__( self.__payload_filter_key = payload_filter_key self._data_source_update_sink: Optional[DataSourceUpdateSink] = None self._instance_id: Optional[str] = None + self._datasystem_config = datasystem_config def copy_with_new_sdk_key(self, new_sdk_key: str) -> 'Config': """Returns a new ``Config`` instance that is the same as this one, except for having a different SDK key. @@ -546,6 +577,15 @@ def data_source_update_sink(self) -> Optional[DataSourceUpdateSink]: """ return self._data_source_update_sink + @property + def datasystem_config(self) -> Optional[DataSystemConfig]: + """ + Configuration for the upcoming enhanced data system design. This is + experimental and should not be set without direction from LaunchDarkly + support. + """ + return self._datasystem_config + def _validate(self): if self.offline is False and self.sdk_key == '': log.warning("Missing or blank SDK key") diff --git a/ldclient/impl/datasourcev2/polling.py b/ldclient/impl/datasourcev2/polling.py index 224f49c5..c77ff8b4 100644 --- a/ldclient/impl/datasourcev2/polling.py +++ b/ldclient/impl/datasourcev2/polling.py @@ -90,6 +90,7 @@ def __init__( "ldclient.datasource.polling", poll_interval, 0, self._poll ) + @property def name(self) -> str: """Returns the name of the initializer.""" return "PollingDataSourceV2" diff --git a/ldclient/impl/datasourcev2/status.py b/ldclient/impl/datasourcev2/status.py new file mode 100644 index 00000000..ca384415 --- /dev/null +++ b/ldclient/impl/datasourcev2/status.py @@ -0,0 +1,57 @@ +import time +from typing import Callable, Optional + +from ldclient.impl.listeners import Listeners +from ldclient.impl.rwlock import ReadWriteLock +from ldclient.interfaces import ( + DataSourceErrorInfo, + DataSourceState, + DataSourceStatus, + DataSourceStatusProvider +) + + +class DataSourceStatusProviderImpl(DataSourceStatusProvider): + def __init__(self, listeners: Listeners): + self.__listeners = listeners + self.__status = DataSourceStatus(DataSourceState.INITIALIZING, 0, None) + self.__lock = ReadWriteLock() + + @property + def status(self) -> DataSourceStatus: + self.__lock.rlock() + status = self.__status + self.__lock.runlock() + + return status + + def update_status(self, new_state: DataSourceState, new_error: Optional[DataSourceErrorInfo]): + status_to_broadcast = None + + try: + self.__lock.lock() + old_status = self.__status + + if new_state == DataSourceState.INTERRUPTED and old_status.state == DataSourceState.INITIALIZING: + new_state = DataSourceState.INITIALIZING + + if new_state == old_status.state and new_error is None: + return + + new_since = self.__status.since if new_state == self.__status.state else time.time() + new_error = self.__status.error if new_error is None else new_error + + self.__status = DataSourceStatus(new_state, new_since, new_error) + + status_to_broadcast = self.__status + finally: + self.__lock.unlock() + + if status_to_broadcast is not None: + self.__listeners.notify(status_to_broadcast) + + def add_listener(self, listener: Callable[[DataSourceStatus], None]): + self.__listeners.add(listener) + + def remove_listener(self, listener: Callable[[DataSourceStatus], None]): + self.__listeners.remove(listener) diff --git a/ldclient/impl/datasourcev2/streaming.py b/ldclient/impl/datasourcev2/streaming.py index 03ea68ff..808b5238 100644 --- a/ldclient/impl/datasourcev2/streaming.py +++ b/ldclient/impl/datasourcev2/streaming.py @@ -129,6 +129,13 @@ def __init__( self._config = config self._sse: Optional[SSEClient] = None + @property + def name(self) -> str: + """ + Returns the name of the synchronizer, which is used for logging and debugging. + """ + return "streaming" + def sync(self) -> Generator[Update, None, None]: """ sync should begin the synchronization process for the data source, yielding diff --git a/ldclient/impl/datastore/status.py b/ldclient/impl/datastore/status.py index a8dd5ee3..ee9797dd 100644 --- a/ldclient/impl/datastore/status.py +++ b/ldclient/impl/datastore/status.py @@ -1,7 +1,7 @@ from __future__ import annotations from copy import copy -from typing import TYPE_CHECKING, Callable +from typing import TYPE_CHECKING, Callable, Protocol from ldclient.impl.listeners import Listeners from ldclient.impl.rwlock import ReadWriteLock diff --git a/ldclient/impl/datasystem/__init__.py b/ldclient/impl/datasystem/__init__.py index 9c5bf6d6..15b9e8f0 100644 --- a/ldclient/impl/datasystem/__init__.py +++ b/ldclient/impl/datasystem/__init__.py @@ -156,6 +156,14 @@ class Initializer(Protocol): # pylint: disable=too-few-public-methods as new changes occur. """ + @property + @abstractmethod + def name(self) -> str: + """ + Returns the name of the initializer, which is used for logging and debugging. + """ + raise NotImplementedError + @abstractmethod def fetch(self) -> BasisResult: """ @@ -188,6 +196,13 @@ class Synchronizer(Protocol): # pylint: disable=too-few-public-methods of the data source, including any changes that have occurred since the last synchronization. """ + @property + @abstractmethod + def name(self) -> str: + """ + Returns the name of the synchronizer, which is used for logging and debugging. + """ + raise NotImplementedError @abstractmethod def sync(self) -> Generator[Update, None, None]: diff --git a/ldclient/impl/datasystem/config.py b/ldclient/impl/datasystem/config.py index c0e66d6b..e9c42efd 100644 --- a/ldclient/impl/datasystem/config.py +++ b/ldclient/impl/datasystem/config.py @@ -2,10 +2,10 @@ Configuration for LaunchDarkly's data acquisition strategy. """ -from dataclasses import dataclass from typing import Callable, List, Optional, TypeVar from ldclient.config import Config as LDConfig +from ldclient.config import DataSystemConfig from ldclient.impl.datasourcev2.polling import ( PollingDataSource, PollingDataSourceBuilder, @@ -22,22 +22,6 @@ Builder = Callable[[], T] -@dataclass(frozen=True) -class Config: - """ - Configuration for LaunchDarkly's data acquisition strategy. - """ - - initializers: Optional[List[Builder[Initializer]]] - """The initializers for the data system.""" - - primary_synchronizer: Builder[Synchronizer] - """The primary synchronizer for the data system.""" - - secondary_synchronizer: Optional[Builder[Synchronizer]] - """The secondary synchronizers for the data system.""" - - class ConfigBuilder: # pylint: disable=too-few-public-methods """ Builder for the data system configuration. @@ -47,7 +31,7 @@ class ConfigBuilder: # pylint: disable=too-few-public-methods _primary_synchronizer: Optional[Builder[Synchronizer]] = None _secondary_synchronizer: Optional[Builder[Synchronizer]] = None - def initializers(self, initializers: List[Builder[Initializer]]) -> "ConfigBuilder": + def initializers(self, initializers: Optional[List[Builder[Initializer]]]) -> "ConfigBuilder": """ Sets the initializers for the data system. """ @@ -66,14 +50,14 @@ def synchronizers( self._secondary_synchronizer = secondary return self - def build(self) -> Config: + def build(self) -> DataSystemConfig: """ Builds the data system configuration. """ if self._primary_synchronizer is None: raise ValueError("Primary synchronizer must be set") - return Config( + return DataSystemConfig( initializers=self._initializers, primary_synchronizer=self._primary_synchronizer, secondary_synchronizer=self._secondary_synchronizer, @@ -144,7 +128,7 @@ def polling(config: LDConfig) -> ConfigBuilder: streaming, but may be necessary in some network environments. """ - polling_builder = __polling_ds_builder(config) + polling_builder: Builder[Synchronizer] = __polling_ds_builder(config) builder = ConfigBuilder() builder.synchronizers(polling_builder) diff --git a/ldclient/impl/datasystem/fdv1.py b/ldclient/impl/datasystem/fdv1.py index d291aba3..e45498e2 100644 --- a/ldclient/impl/datasystem/fdv1.py +++ b/ldclient/impl/datasystem/fdv1.py @@ -142,7 +142,16 @@ def flag_tracker(self) -> FlagTracker: @property def data_availability(self) -> DataAvailability: - return self._data_availability + if self._config.offline: + return DataAvailability.DEFAULTS + + if self._update_processor is not None and self._update_processor.initialized(): + return DataAvailability.REFRESHED + + if self._store_wrapper.initialized: + return DataAvailability.CACHED + + return DataAvailability.DEFAULTS @property def target_availability(self) -> DataAvailability: diff --git a/ldclient/impl/datasystem/fdv2.py b/ldclient/impl/datasystem/fdv2.py new file mode 100644 index 00000000..cfb61750 --- /dev/null +++ b/ldclient/impl/datasystem/fdv2.py @@ -0,0 +1,415 @@ +import time +from threading import Event, Thread +from typing import Callable, List, Optional + +from ldclient.config import Builder, DataSystemConfig +from ldclient.impl.datasourcev2.status import DataSourceStatusProviderImpl +from ldclient.impl.datasystem import DataAvailability, Synchronizer +from ldclient.impl.datasystem.store import Store +from ldclient.impl.flag_tracker import FlagTrackerImpl +from ldclient.impl.listeners import Listeners +from ldclient.impl.util import _Fail +from ldclient.interfaces import ( + DataSourceState, + DataSourceStatus, + DataSourceStatusProvider, + DataStoreStatusProvider, + FeatureStore, + FlagTracker +) + + +class FDv2: + """ + FDv2 is an implementation of the DataSystem interface that uses the Flag Delivery V2 protocol + for obtaining and keeping data up-to-date. Additionally, it operates with an optional persistent + store in read-only or read/write mode. + """ + + def __init__( + self, + config: DataSystemConfig, + # # TODO: These next 2 parameters should be moved into the Config. + # persistent_store: Optional[FeatureStore] = None, + # store_writable: bool = True, + disabled: bool = False, + ): + """ + Initialize a new FDv2 data system. + + :param config: Configuration for initializers and synchronizers + :param persistent_store: Optional persistent store for data persistence + :param store_writable: Whether the persistent store should be written to + :param disabled: Whether the data system is disabled (offline mode) + """ + self._config = config + self._primary_synchronizer_builder: Optional[Builder[Synchronizer]] = config.primary_synchronizer + self._secondary_synchronizer_builder = config.secondary_synchronizer + self._fdv1_fallback_synchronizer_builder = config.fdv1_fallback_synchronizer + self._disabled = disabled + + # Diagnostic accumulator provided by client for streaming metrics + # TODO(fdv2): Either we need to use this, or we need to provide it to + # the streaming synchronizers + self._diagnostic_accumulator = None + + # Set up event listeners + self._flag_change_listeners = Listeners() + self._change_set_listeners = Listeners() + + # Create the store + self._store = Store(self._flag_change_listeners, self._change_set_listeners) + + # Status providers + self._data_source_status_provider = DataSourceStatusProviderImpl(Listeners()) + + # # Configure persistent store if provided + # if persistent_store is not None: + # self._store.with_persistence( + # persistent_store, store_writable, self._data_source_status_provider + # ) + # + # Flag tracker (evaluation function set later by client) + self._flag_tracker = FlagTrackerImpl( + self._flag_change_listeners, + lambda key, context: None # Placeholder, replaced by client + ) + + # Threading + self._stop_event = Event() + self._threads: List[Thread] = [] + + # Track configuration + # TODO: What is the point of checking if primary_synchronizer is not + # None? Doesn't it have to be set? + self._configured_with_data_sources = ( + (config.initializers is not None and len(config.initializers) > 0) + or config.primary_synchronizer is not None + ) + + def start(self, set_on_ready: Event): + """ + Start the FDv2 data system. + + :param set_on_ready: Event to set when the system is ready or has failed + """ + if self._disabled: + print("Data system is disabled, SDK will return application-defined default values") + set_on_ready.set() + return + + self._stop_event.clear() + + # Start the main coordination thread + main_thread = Thread( + target=self._run_main_loop, + args=(set_on_ready,), + name="FDv2-main", + daemon=True + ) + main_thread.start() + self._threads.append(main_thread) + + def stop(self): + """Stop the FDv2 data system and all associated threads.""" + self._stop_event.set() + + # Wait for all threads to complete + for thread in self._threads: + if thread.is_alive(): + thread.join(timeout=5.0) # 5 second timeout + + # Close the store + self._store.close() + + def set_diagnostic_accumulator(self, diagnostic_accumulator): + """ + Sets the diagnostic accumulator for streaming initialization metrics. + This should be called before start() to ensure metrics are collected. + """ + self._diagnostic_accumulator = diagnostic_accumulator + + def _run_main_loop(self, set_on_ready: Event): + """Main coordination loop that manages initializers and synchronizers.""" + try: + self._data_source_status_provider.update_status( + DataSourceState.INITIALIZING, None + ) + + # Run initializers first + self._run_initializers(set_on_ready) + + # # If we have persistent store with status monitoring, start recovery monitoring + # if ( + # self._configured_with_data_sources + # and self._data_store_status_provider is not None + # and hasattr(self._data_store_status_provider, 'add_listener') + # ): + # recovery_thread = Thread( + # target=self._run_persistent_store_outage_recovery, + # name="FDv2-store-recovery", + # daemon=True + # ) + # recovery_thread.start() + # self._threads.append(recovery_thread) + + # Run synchronizers + self._run_synchronizers(set_on_ready) + + except Exception as e: + print(f"Error in FDv2 main loop: {e}") + # Ensure ready event is set even on error + if not set_on_ready.is_set(): + set_on_ready.set() + + def _run_initializers(self, set_on_ready: Event): + """Run initializers to get initial data.""" + if self._config.initializers is None: + return + + for initializer_builder in self._config.initializers: + if self._stop_event.is_set(): + return + + try: + initializer = initializer_builder() + print(f"Attempting to initialize via {initializer.name}") + + basis_result = initializer.fetch() + + if isinstance(basis_result, _Fail): + print(f"Initializer {initializer.name} failed: {basis_result.error}") + continue + + basis = basis_result.value + print(f"Initialized via {initializer.name}") + + # Apply the basis to the store + self._store.apply(basis.change_set, basis.persist) + + # Set ready event + if not set_on_ready.is_set(): + set_on_ready.set() + except Exception as e: + print(f"Initializer failed with exception: {e}") + + def _run_synchronizers(self, set_on_ready: Event): + """Run synchronizers to keep data up-to-date.""" + # If no primary synchronizer configured, just set ready and return + if self._config.primary_synchronizer is None: + if not set_on_ready.is_set(): + set_on_ready.set() + return + + def synchronizer_loop(self: 'FDv2'): + try: + # Always ensure ready event is set when we exit + while not self._stop_event.is_set() and self._primary_synchronizer_builder is not None: + # Try primary synchronizer + try: + primary_sync = self._primary_synchronizer_builder() + print(f"Primary synchronizer {primary_sync.name} is starting") + + remove_sync, fallback_v1 = self._consume_synchronizer_results( + primary_sync, set_on_ready, self._fallback_condition + ) + + if remove_sync: + self._primary_synchronizer_builder = self._secondary_synchronizer_builder + self._secondary_synchronizer_builder = None + + if fallback_v1: + self._primary_synchronizer_builder = self._fdv1_fallback_synchronizer_builder + + if self._primary_synchronizer_builder is None: + print("No more synchronizers available") + self._data_source_status_provider.update_status( + DataSourceState.OFF, + self._data_source_status_provider.status.error + ) + break + else: + print("Fallback condition met") + + if self._secondary_synchronizer_builder is None: + continue + + secondary_sync = self._secondary_synchronizer_builder() + print(f"Secondary synchronizer {secondary_sync.name} is starting") + + remove_sync, fallback_v1 = self._consume_synchronizer_results( + secondary_sync, set_on_ready, self._recovery_condition + ) + + if remove_sync: + self._secondary_synchronizer_builder = None + if fallback_v1: + self._primary_synchronizer_builder = self._fdv1_fallback_synchronizer_builder + + if self._primary_synchronizer_builder is None: + print("No more synchronizers available") + self._data_source_status_provider.update_status( + DataSourceState.OFF, + self._data_source_status_provider.status.error + ) + # TODO: WE might need to also set that threading.Event here + break + + print("Recovery condition met, returning to primary synchronizer") + except Exception as e: + print(f"Failed to build primary synchronizer: {e}") + break + + except Exception as e: + print(f"Error in synchronizer loop: {e}") + finally: + # Ensure we always set the ready event when exiting + if not set_on_ready.is_set(): + set_on_ready.set() + + sync_thread = Thread( + target=synchronizer_loop, + name="FDv2-synchronizers", + args=(self,), + daemon=True + ) + sync_thread.start() + self._threads.append(sync_thread) + + def _consume_synchronizer_results( + self, + synchronizer: Synchronizer, + set_on_ready: Event, + condition_func: Callable[[DataSourceStatus], bool] + ) -> tuple[bool, bool]: + """ + Consume results from a synchronizer until a condition is met or it fails. + + :return: Tuple of (should_remove_sync, fallback_to_fdv1) + """ + try: + for update in synchronizer.sync(): + print(f"Synchronizer {synchronizer.name} update: {update.state}") + if self._stop_event.is_set(): + return False, False + + # Handle the update + if update.change_set is not None: + self._store.apply(update.change_set, True) + + # Set ready event on first valid update + if update.state == DataSourceState.VALID and not set_on_ready.is_set(): + set_on_ready.set() + + # Update status + self._data_source_status_provider.update_status(update.state, update.error) + + # Check for OFF state indicating permanent failure + if update.state == DataSourceState.OFF: + return True, update.revert_to_fdv1 + + # Check condition periodically + current_status = self._data_source_status_provider.status + if condition_func(current_status): + return False, False + + except Exception as e: + print(f"Error consuming synchronizer results: {e}") + return True, False + + return True, False + + # def _run_persistent_store_outage_recovery(self): + # """Monitor persistent store status and trigger recovery when needed.""" + # # This is a simplified version - in a full implementation we'd need + # # to properly monitor store status and trigger commit operations + # # when the store comes back online after an outage + # pass + # + def _fallback_condition(self, status: DataSourceStatus) -> bool: + """ + Determine if we should fallback to secondary synchronizer. + + :param status: Current data source status + :return: True if fallback condition is met + """ + interrupted_at_runtime = ( + status.state == DataSourceState.INTERRUPTED + and time.time() - status.since > 60 # 1 minute + ) + cannot_initialize = ( + status.state == DataSourceState.INITIALIZING + and time.time() - status.since > 10 # 10 seconds + ) + + return interrupted_at_runtime or cannot_initialize + + def _recovery_condition(self, status: DataSourceStatus) -> bool: + """ + Determine if we should try to recover to primary synchronizer. + + :param status: Current data source status + :return: True if recovery condition is met + """ + interrupted_at_runtime = ( + status.state == DataSourceState.INTERRUPTED + and time.time() - status.since > 60 # 1 minute + ) + healthy_for_too_long = ( + status.state == DataSourceState.VALID + and time.time() - status.since > 300 # 5 minutes + ) + cannot_initialize = ( + status.state == DataSourceState.INITIALIZING + and time.time() - status.since > 10 # 10 seconds + ) + + return interrupted_at_runtime or healthy_for_too_long or cannot_initialize + + @property + def store(self) -> FeatureStore: + """Get the underlying store for flag evaluation.""" + return self._store.get_active_store() + + def set_flag_value_eval_fn(self, eval_fn): + """ + Set the flag value evaluation function for the flag tracker. + + :param eval_fn: Function with signature (key: str, context: Context) -> Any + """ + self._flag_tracker = FlagTrackerImpl(self._flag_change_listeners, eval_fn) + + @property + def data_source_status_provider(self) -> DataSourceStatusProvider: + """Get the data source status provider.""" + return self._data_source_status_provider + + @property + def data_store_status_provider(self) -> DataStoreStatusProvider: + """Get the data store status provider.""" + raise NotImplementedError + # return self._data_store_status_provider + + @property + def flag_tracker(self) -> FlagTracker: + """Get the flag tracker for monitoring flag changes.""" + return self._flag_tracker + + @property + def data_availability(self) -> DataAvailability: + """Get the current data availability level.""" + if self._store.selector().is_defined(): + return DataAvailability.REFRESHED + + if not self._configured_with_data_sources or self._store.is_initialized(): + return DataAvailability.CACHED + + return DataAvailability.DEFAULTS + + @property + def target_availability(self) -> DataAvailability: + """Get the target data availability level based on configuration.""" + if self._configured_with_data_sources: + return DataAvailability.REFRESHED + + return DataAvailability.CACHED diff --git a/ldclient/impl/datasystem/store.py b/ldclient/impl/datasystem/store.py new file mode 100644 index 00000000..435a0faf --- /dev/null +++ b/ldclient/impl/datasystem/store.py @@ -0,0 +1,355 @@ +""" +Store implementation for FDv2 data system. + +This module provides a dual-mode persistent/in-memory store that serves requests for data +from the evaluation algorithm. It manages both in-memory and persistent storage, handling +ChangeSet applications and flag change notifications. +""" + +import threading +from typing import Dict, List, Mapping, Optional, Set + +from ldclient.feature_store import InMemoryFeatureStore +from ldclient.impl.datasystem.protocolv2 import ( + Change, + ChangeSet, + ChangeType, + IntentCode, + ObjectKind, + Selector +) +from ldclient.impl.dependency_tracker import DependencyTracker, KindAndKey +from ldclient.impl.listeners import Listeners +from ldclient.interfaces import ( + DataStoreStatusProvider, + FeatureStore, + FlagChange +) +from ldclient.versioned_data_kind import FEATURES, SEGMENTS, VersionedDataKind + + +class Store: + """ + Store is a dual-mode persistent/in-memory store that serves requests for data from the evaluation + algorithm. + + At any given moment one of two stores is active: in-memory, or persistent. Once the in-memory + store has data (either from initializers or a synchronizer), the persistent store is no longer + read from. From that point forward, calls to get data will serve from the memory store. + """ + + def __init__( + self, + flag_change_listeners: Listeners, + change_set_listeners: Listeners, + ): + """ + Initialize a new Store. + + Args: + flag_change_listeners: Listeners for flag change events + change_set_listeners: Listeners for changeset events + """ + self._persistent_store: Optional[FeatureStore] = None + self._persistent_store_status_provider: Optional[DataStoreStatusProvider] = None + self._persistent_store_writable = False + + # Source of truth for flag evaluations once initialized + self._memory_store = InMemoryFeatureStore() + + # Used to track dependencies between items in the store + self._dependency_tracker = DependencyTracker() + + # Listeners for events + self._flag_change_listeners = flag_change_listeners + self._change_set_listeners = change_set_listeners + + # True if the data in the memory store may be persisted to the persistent store + self._persist = False + + # Points to the active store. Swapped upon initialization. + self._active_store: FeatureStore = self._memory_store + + # Identifies the current data + self._selector = Selector.no_selector() + + # Thread synchronization + self._lock = threading.RLock() + + def with_persistence( + self, + persistent_store: FeatureStore, + writable: bool, + status_provider: Optional[DataStoreStatusProvider] = None, + ) -> "Store": + """ + Configure the store with a persistent store for read-only or read-write access. + + Args: + persistent_store: The persistent store implementation + writable: Whether the persistent store should be written to + status_provider: Optional status provider for the persistent store + + Returns: + Self for method chaining + """ + with self._lock: + self._persistent_store = persistent_store + self._persistent_store_writable = writable + self._persistent_store_status_provider = status_provider + + # Initially use persistent store as active until memory store has data + self._active_store = persistent_store + + return self + + def selector(self) -> Selector: + """Returns the current selector.""" + with self._lock: + return self._selector + + def close(self) -> Optional[Exception]: + """Close the store and any persistent store if configured.""" + with self._lock: + if self._persistent_store is not None: + try: + # Most FeatureStore implementations don't have close methods + # but we'll try to call it if it exists + if hasattr(self._persistent_store, 'close'): + self._persistent_store.close() + except Exception as e: + return e + return None + + def apply(self, change_set: ChangeSet, persist: bool) -> None: + """ + Apply a changeset to the store. + + Args: + change_set: The changeset to apply + persist: Whether the changes should be persisted to the persistent store + """ + with self._lock: + try: + if change_set.intent_code == IntentCode.TRANSFER_FULL: + self._set_basis(change_set, persist) + elif change_set.intent_code == IntentCode.TRANSFER_CHANGES: + self._apply_delta(change_set, persist) + elif change_set.intent_code == IntentCode.TRANSFER_NONE: + # No-op, no changes to apply + return + + # Notify changeset listeners + self._change_set_listeners.notify(change_set) + + except Exception as e: + # Log error but don't re-raise - matches Go behavior + print(f"Store: couldn't apply changeset: {e}") + + def _set_basis(self, change_set: ChangeSet, persist: bool) -> None: + """ + Set the basis of the store. Any existing data is discarded. + + Args: + change_set: The changeset containing the new basis data + persist: Whether to persist the data to the persistent store + """ + # Take snapshot for change detection if we have flag listeners + old_data: Optional[Mapping[VersionedDataKind, Mapping[str, dict]]] = None + if self._flag_change_listeners.has_listeners(): + old_data = {} + for kind in [FEATURES, SEGMENTS]: + old_data[kind] = self._memory_store.all(kind, lambda x: x) + + # Convert changes to the format expected by FeatureStore.init() + all_data = self._changes_to_store_data(change_set.changes) + + # Initialize memory store with new data + self._memory_store.init(all_data) + + # Update dependency tracker + self._reset_dependency_tracker(all_data) + + # Send change events if we had listeners + if old_data is not None: + affected_items = self._compute_changed_items_for_full_data_set(old_data, all_data) + self._send_change_events(affected_items) + + # Update state + self._persist = persist + if change_set.selector is not None: + self._selector = change_set.selector + + # Switch to memory store as active + self._active_store = self._memory_store + + # Persist to persistent store if configured and writable + if self._should_persist(): + self._persistent_store.init(all_data) # type: ignore + + def _apply_delta(self, change_set: ChangeSet, persist: bool) -> None: + """ + Apply a delta update to the store. + + Args: + change_set: The changeset containing the delta changes + persist: Whether to persist the changes to the persistent store + """ + has_listeners = self._flag_change_listeners.has_listeners() + affected_items: Set[KindAndKey] = set() + + # Apply each change + for change in change_set.changes: + if change.action == ChangeType.PUT: + # Convert to VersionedDataKind + kind = FEATURES if change.kind == ObjectKind.FLAG else SEGMENTS + item = change.object + if item is not None: + self._memory_store.upsert(kind, item) + + # Update dependency tracking + self._dependency_tracker.update_dependencies_from(kind, change.key, item) + if has_listeners: + self._dependency_tracker.add_affected_items( + affected_items, KindAndKey(kind=kind, key=change.key) + ) + + # Persist to persistent store if configured + if self._should_persist(): + self._persistent_store.upsert(kind, item) # type: ignore + + elif change.action == ChangeType.DELETE: + # Convert to VersionedDataKind + kind = FEATURES if change.kind == ObjectKind.FLAG else SEGMENTS + self._memory_store.delete(kind, change.key, change.version) + + # Update dependency tracking + self._dependency_tracker.update_dependencies_from(kind, change.key, None) + if has_listeners: + self._dependency_tracker.add_affected_items( + affected_items, KindAndKey(kind=kind, key=change.key) + ) + + # Persist to persistent store if configured + if self._should_persist(): + self._persistent_store.delete(kind, change.key, change.version) # type: ignore + + # Send change events + if affected_items: + self._send_change_events(affected_items) + + # Update state + self._persist = persist + if change_set.selector is not None: + self._selector = change_set.selector + + def _should_persist(self) -> bool: + """Returns whether data should be persisted to the persistent store.""" + return ( + self._persist + and self._persistent_store is not None + and self._persistent_store_writable + ) + + def _changes_to_store_data( + self, changes: List[Change] + ) -> Mapping[VersionedDataKind, Mapping[str, dict]]: + """ + Convert a list of Changes to the format expected by FeatureStore.init(). + + Args: + changes: List of changes to convert + + Returns: + Mapping suitable for FeatureStore.init() + """ + all_data: Dict[VersionedDataKind, Dict[str, dict]] = { + FEATURES: {}, + SEGMENTS: {}, + } + + for change in changes: + if change.action == ChangeType.PUT and change.object is not None: + kind = FEATURES if change.kind == ObjectKind.FLAG else SEGMENTS + all_data[kind][change.key] = change.object + + return all_data + + def _reset_dependency_tracker( + self, all_data: Mapping[VersionedDataKind, Mapping[str, dict]] + ) -> None: + """Reset dependency tracker with new full data set.""" + self._dependency_tracker.reset() + for kind, items in all_data.items(): + for key, item in items.items(): + self._dependency_tracker.update_dependencies_from(kind, key, item) + + def _send_change_events(self, affected_items: Set[KindAndKey]) -> None: + """Send flag change events for affected items.""" + for item in affected_items: + if item.kind == FEATURES: + self._flag_change_listeners.notify(FlagChange(item.key)) + + def _compute_changed_items_for_full_data_set( + self, + old_data: Mapping[VersionedDataKind, Mapping[str, dict]], + new_data: Mapping[VersionedDataKind, Mapping[str, dict]], + ) -> Set[KindAndKey]: + """Compute which items changed between old and new data sets.""" + affected_items: Set[KindAndKey] = set() + + for kind in [FEATURES, SEGMENTS]: + old_items = old_data.get(kind, {}) + new_items = new_data.get(kind, {}) + + # Get all keys from both old and new data + all_keys = set(old_items.keys()) | set(new_items.keys()) + + for key in all_keys: + old_item = old_items.get(key) + new_item = new_items.get(key) + + # If either is missing or versions differ, it's a change + if old_item is None or new_item is None: + self._dependency_tracker.add_affected_items( + affected_items, KindAndKey(kind=kind, key=key) + ) + elif old_item.get("version", 0) != new_item.get("version", 0): + self._dependency_tracker.add_affected_items( + affected_items, KindAndKey(kind=kind, key=key) + ) + + return affected_items + + def commit(self) -> Optional[Exception]: + """ + Commit persists the data in the memory store to the persistent store, if configured. + + Returns: + Exception if commit failed, None otherwise + """ + with self._lock: + if self._should_persist(): + try: + # Get all data from memory store and write to persistent store + all_data = {} + for kind in [FEATURES, SEGMENTS]: + all_data[kind] = self._memory_store.all(kind, lambda x: x) + self._persistent_store.init(all_data) # type: ignore + except Exception as e: + return e + return None + + def get_active_store(self) -> FeatureStore: + """Get the currently active store for reading data.""" + with self._lock: + return self._active_store + + def is_initialized(self) -> bool: + """Check if the active store is initialized.""" + return self.get_active_store().initialized + + def get_data_store_status_provider(self) -> Optional[DataStoreStatusProvider]: + """Get the data store status provider for the persistent store, if configured.""" + with self._lock: + return self._persistent_store_status_provider diff --git a/ldclient/impl/integrations/test_datav2/test_data_sourcev2.py b/ldclient/impl/integrations/test_datav2/test_data_sourcev2.py index 12f68c92..bf3397c3 100644 --- a/ldclient/impl/integrations/test_datav2/test_data_sourcev2.py +++ b/ldclient/impl/integrations/test_datav2/test_data_sourcev2.py @@ -42,6 +42,11 @@ def __init__(self, test_data): # - Added to `upsert_flag` to address potential race conditions. # - The `sync` method relies on Queue's thread-safe properties for updates. + @property + def name(self) -> str: + """Return the name of this data source.""" + return "TestDataV2" + def fetch(self) -> BasisResult: """ Implementation of the Initializer.fetch method. diff --git a/ldclient/testing/impl/datasourcev2/test_polling_initializer.py b/ldclient/testing/impl/datasourcev2/test_polling_initializer.py index be2e538f..0a7079d6 100644 --- a/ldclient/testing/impl/datasourcev2/test_polling_initializer.py +++ b/ldclient/testing/impl/datasourcev2/test_polling_initializer.py @@ -30,7 +30,7 @@ def test_polling_has_a_name(): mock_requester = MockPollingRequester(_Fail(error="failure message")) ds = PollingDataSource(poll_interval=1.0, requester=mock_requester) - assert ds.name() == "PollingDataSourceV2" + assert ds.name == "PollingDataSourceV2" def test_error_is_returned_on_failure(): diff --git a/ldclient/testing/impl/datasourcev2/test_polling_synchronizer.py b/ldclient/testing/impl/datasourcev2/test_polling_synchronizer.py index ff8bf2eb..92391368 100644 --- a/ldclient/testing/impl/datasourcev2/test_polling_synchronizer.py +++ b/ldclient/testing/impl/datasourcev2/test_polling_synchronizer.py @@ -202,86 +202,6 @@ def test_handles_delete_objects(): assert valid.change_set.intent_code == IntentCode.TRANSFER_FULL -# def test_swallows_goodbye(events): # pylint: disable=redefined-outer-name -# builder = list_sse_client( -# [ -# events[EventName.SERVER_INTENT], -# events[EventName.GOODBYE], -# events[EventName.PAYLOAD_TRANSFERRED], -# ] -# ) -# -# synchronizer = StreamingSynchronizer(Config(sdk_key="key"), builder) -# updates = list(synchronizer.sync()) -# -# builder = ChangeSetBuilder() -# builder.start(intent=IntentCode.TRANSFER_FULL) -# change_set = builder.finish(selector=Selector(state="p:SOMETHING:300", version=300)) -# headers = {} -# polling_result: PollingResult = _Success(value=(change_set, headers)) -# -# synchronizer = PollingDataSource( -# poll_interval=0.01, requester=ListBasedRequester(results=iter([polling_result])) -# ) -# updates = list(synchronizer.sync()) -# -# assert len(updates) == 1 -# assert updates[0].state == DataSourceState.VALID -# assert updates[0].error is None -# assert updates[0].revert_to_fdv1 is False -# assert updates[0].environment_id is None -# -# assert updates[0].change_set is not None -# assert len(updates[0].change_set.changes) == 1 -# assert updates[0].change_set.changes[0].action == ChangeType.DELETE -# assert updates[0].change_set.changes[0].kind == ObjectKind.FLAG -# assert updates[0].change_set.changes[0].key == "flag-key" -# assert updates[0].change_set.changes[0].version == 101 -# assert updates[0].change_set.selector is not None -# assert updates[0].change_set.selector.version == 300 -# assert updates[0].change_set.selector.state == "p:SOMETHING:300" -# assert updates[0].change_set.intent_code == IntentCode.TRANSFER_FULL -# -# assert len(updates) == 1 -# assert updates[0].state == DataSourceState.VALID -# assert updates[0].error is None -# assert updates[0].revert_to_fdv1 is False -# assert updates[0].environment_id is None -# -# assert updates[0].change_set is not None -# assert len(updates[0].change_set.changes) == 0 -# assert updates[0].change_set.selector is not None -# assert updates[0].change_set.selector.version == 300 -# assert updates[0].change_set.selector.state == "p:SOMETHING:300" -# assert updates[0].change_set.intent_code == IntentCode.TRANSFER_FULL -# -# -# def test_swallows_heartbeat(events): # pylint: disable=redefined-outer-name -# builder = list_sse_client( -# [ -# events[EventName.SERVER_INTENT], -# events[EventName.HEARTBEAT], -# events[EventName.PAYLOAD_TRANSFERRED], -# ] -# ) -# -# synchronizer = StreamingSynchronizer(Config(sdk_key="key"), builder) -# updates = list(synchronizer.sync()) -# -# assert len(updates) == 1 -# assert updates[0].state == DataSourceState.VALID -# assert updates[0].error is None -# assert updates[0].revert_to_fdv1 is False -# assert updates[0].environment_id is None -# -# assert updates[0].change_set is not None -# assert len(updates[0].change_set.changes) == 0 -# assert updates[0].change_set.selector is not None -# assert updates[0].change_set.selector.version == 300 -# assert updates[0].change_set.selector.state == "p:SOMETHING:300" -# assert updates[0].change_set.intent_code == IntentCode.TRANSFER_FULL -# -# def test_generic_error_interrupts_and_recovers(): builder = ChangeSetBuilder() builder.start(intent=IntentCode.TRANSFER_FULL) diff --git a/ldclient/testing/impl/datasystem/test_config.py b/ldclient/testing/impl/datasystem/test_config.py index c7c0925b..db73aece 100644 --- a/ldclient/testing/impl/datasystem/test_config.py +++ b/ldclient/testing/impl/datasystem/test_config.py @@ -1,12 +1,11 @@ import dataclasses -from unittest.mock import MagicMock, Mock +from unittest.mock import Mock import pytest from ldclient.config import Config as LDConfig -from ldclient.impl.datasystem import Initializer, Synchronizer +from ldclient.config import DataSystemConfig from ldclient.impl.datasystem.config import ( - Config, ConfigBuilder, custom, default, @@ -63,7 +62,7 @@ def test_config_builder_build_success(): config = builder.build() - assert isinstance(config, Config) + assert isinstance(config, DataSystemConfig) assert config.initializers == [mock_initializer] assert config.primary_synchronizer == mock_primary assert config.secondary_synchronizer == mock_secondary @@ -178,11 +177,11 @@ def test_polling_config_builder(): def test_config_dataclass_immutability(): - """Test that Config instances are immutable (frozen dataclass).""" + """Test that DataSystemConfig instances are immutable (frozen dataclass).""" mock_primary = Mock() mock_secondary = Mock() - config = Config( + config = DataSystemConfig( initializers=None, primary_synchronizer=mock_primary, secondary_synchronizer=mock_secondary, diff --git a/ldclient/testing/impl/datasystem/test_fdv2_datasystem.py b/ldclient/testing/impl/datasystem/test_fdv2_datasystem.py new file mode 100644 index 00000000..b0db1426 --- /dev/null +++ b/ldclient/testing/impl/datasystem/test_fdv2_datasystem.py @@ -0,0 +1,159 @@ +# pylint: disable=missing-docstring + +from threading import Event +from typing import List + +from mock import Mock + +from ldclient.config import DataSystemConfig +from ldclient.impl.datasystem import DataAvailability, Synchronizer +from ldclient.impl.datasystem.fdv2 import FDv2 +from ldclient.integrations.test_datav2 import TestDataV2 +from ldclient.interfaces import DataSourceState, DataSourceStatus, FlagChange + + +def test_two_phase_init(): + td_initializer = TestDataV2.data_source() + td_initializer.update(td_initializer.flag("feature-flag").on(True)) + + td_synchronizer = TestDataV2.data_source() + td_synchronizer.update(td_synchronizer.flag("feature-flag").on(True)) + config = DataSystemConfig( + initializers=[td_initializer.build_initializer], + primary_synchronizer=td_synchronizer.build_synchronizer, + ) + + set_on_ready = Event() + fdv2 = FDv2(config) + + changed = Event() + changes: List[FlagChange] = [] + count = 0 + + def listener(flag_change: FlagChange): + nonlocal count, changes + count += 1 + changes.append(flag_change) + + if count == 2: + changed.set() + + fdv2.flag_tracker.add_listener(listener) + + fdv2.start(set_on_ready) + assert set_on_ready.wait(1), "Data system did not become ready in time" + + td_synchronizer.update(td_synchronizer.flag("feature-flag").on(False)) + assert changed.wait(1), "Flag change listener was not called in time" + assert len(changes) == 2 + assert changes[0].key == "feature-flag" + assert changes[1].key == "feature-flag" + + +def test_can_stop_fdv2(): + td = TestDataV2.data_source() + config = DataSystemConfig( + initializers=None, + primary_synchronizer=td.build_synchronizer, + ) + + set_on_ready = Event() + fdv2 = FDv2(config) + + changed = Event() + changes: List[FlagChange] = [] + + def listener(flag_change: FlagChange): + changes.append(flag_change) + changed.set() + + fdv2.flag_tracker.add_listener(listener) + + fdv2.start(set_on_ready) + assert set_on_ready.wait(1), "Data system did not become ready in time" + + fdv2.stop() + + td.update(td.flag("feature-flag").on(False)) + assert changed.wait(1) is False, "Flag change listener was erroneously called" + assert len(changes) == 0 + + +def test_fdv2_data_availability_is_refreshed_with_data(): + td = TestDataV2.data_source() + config = DataSystemConfig( + initializers=None, + primary_synchronizer=td.build_synchronizer, + ) + + set_on_ready = Event() + fdv2 = FDv2(config) + + fdv2.start(set_on_ready) + assert set_on_ready.wait(1), "Data system did not become ready in time" + + assert fdv2.data_availability.at_least(DataAvailability.REFRESHED) + assert fdv2.target_availability.at_least(DataAvailability.REFRESHED) + + +def test_fdv2_fallsback_to_secondary_synchronizer(): + mock: Synchronizer = Mock() + mock.sync.return_value = iter([]) # Empty iterator to simulate no data + td = TestDataV2.data_source() + td.update(td.flag("feature-flag").on(True)) + config = DataSystemConfig( + initializers=[td.build_initializer], + primary_synchronizer=lambda: mock, # Primary synchronizer is None to force fallback + secondary_synchronizer=td.build_synchronizer, + ) + + changed = Event() + changes: List[FlagChange] = [] + count = 0 + + def listener(flag_change: FlagChange): + nonlocal count, changes + count += 1 + changes.append(flag_change) + + if count == 2: + changed.set() + + set_on_ready = Event() + fdv2 = FDv2(config) + fdv2.flag_tracker.add_listener(listener) + fdv2.start(set_on_ready) + assert set_on_ready.wait(1), "Data system did not become ready in time" + + td.update(td.flag("feature-flag").on(False)) + assert changed.wait(1), "Flag change listener was not called in time" + assert len(changes) == 2 + assert changes[0].key == "feature-flag" + assert changes[1].key == "feature-flag" + + +def test_fdv2_shutdown_down_if_both_synchronizers_fail(): + mock: Synchronizer = Mock() + mock.sync.return_value = iter([]) # Empty iterator to simulate no data + td = TestDataV2.data_source() + td.update(td.flag("feature-flag").on(True)) + config = DataSystemConfig( + initializers=[td.build_initializer], + primary_synchronizer=lambda: mock, # Primary synchronizer is None to force fallback + secondary_synchronizer=lambda: mock, # Secondary synchronizer also fails + ) + + changed = Event() + + def listener(status: DataSourceStatus): + if status.state == DataSourceState.OFF: + changed.set() + + set_on_ready = Event() + fdv2 = FDv2(config) + fdv2.data_source_status_provider.add_listener(listener) + fdv2.start(set_on_ready) + assert set_on_ready.wait(1), "Data system did not become ready in time" + + assert changed.wait(1), "Data system did not shut down in time" + assert fdv2.data_source_status_provider.status.state == DataSourceState.OFF From 9b802eccac9ee8cf0b96c769ab1c8b3f1def7ba8 Mon Sep 17 00:00:00 2001 From: "Matthew M. Keeler" Date: Thu, 30 Oct 2025 16:02:58 -0400 Subject: [PATCH 02/13] chore: Add persistence store support for FDv2 (#357) --- ldclient/config.py | 13 +- ldclient/impl/datasourcev2/status.py | 54 +- ldclient/impl/datasystem/config.py | 48 +- ldclient/impl/datasystem/fdv2.py | 209 +++++-- ldclient/impl/datasystem/store.py | 3 +- ldclient/interfaces.py | 44 +- .../testing/impl/datasystem/test_config.py | 19 - .../impl/datasystem/test_fdv2_persistence.py | 524 ++++++++++++++++++ 8 files changed, 820 insertions(+), 94 deletions(-) create mode 100644 ldclient/testing/impl/datasystem/test_fdv2_persistence.py diff --git a/ldclient/config.py b/ldclient/config.py index af5e62b7..01e12fec 100644 --- a/ldclient/config.py +++ b/ldclient/config.py @@ -19,6 +19,7 @@ from ldclient.interfaces import ( BigSegmentStore, DataSourceUpdateSink, + DataStoreMode, EventProcessor, FeatureStore, UpdateProcessor @@ -161,19 +162,23 @@ def disable_ssl_verification(self) -> bool: @dataclass(frozen=True) class DataSystemConfig: - """ - Configuration for LaunchDarkly's data acquisition strategy. - """ + """Configuration for LaunchDarkly's data acquisition strategy.""" initializers: Optional[List[Builder[Initializer]]] """The initializers for the data system.""" - primary_synchronizer: Builder[Synchronizer] + primary_synchronizer: Optional[Builder[Synchronizer]] """The primary synchronizer for the data system.""" secondary_synchronizer: Optional[Builder[Synchronizer]] = None """The secondary synchronizers for the data system.""" + data_store_mode: DataStoreMode = DataStoreMode.READ_WRITE + """The data store mode specifies the mode in which the persistent store will operate, if present.""" + + data_store: Optional[FeatureStore] = None + """The (optional) persistent data store instance.""" + # TODO(fdv2): Implement this synchronizer up and hook it up everywhere. # TODO(fdv2): Remove this when FDv2 is fully launched fdv1_fallback_synchronizer: Optional[Builder[Synchronizer]] = None diff --git a/ldclient/impl/datasourcev2/status.py b/ldclient/impl/datasourcev2/status.py index ca384415..3f417f34 100644 --- a/ldclient/impl/datasourcev2/status.py +++ b/ldclient/impl/datasourcev2/status.py @@ -1,13 +1,18 @@ import time +from copy import copy from typing import Callable, Optional +from ldclient.impl.datasystem.store import Store from ldclient.impl.listeners import Listeners from ldclient.impl.rwlock import ReadWriteLock from ldclient.interfaces import ( DataSourceErrorInfo, DataSourceState, DataSourceStatus, - DataSourceStatusProvider + DataSourceStatusProvider, + DataStoreStatus, + DataStoreStatusProvider, + FeatureStore ) @@ -55,3 +60,50 @@ def add_listener(self, listener: Callable[[DataSourceStatus], None]): def remove_listener(self, listener: Callable[[DataSourceStatus], None]): self.__listeners.remove(listener) + + +class DataStoreStatusProviderImpl(DataStoreStatusProvider): + def __init__(self, store: Optional[FeatureStore], listeners: Listeners): + self.__store = store + self.__listeners = listeners + + self.__lock = ReadWriteLock() + self.__status = DataStoreStatus(True, False) + + def update_status(self, status: DataStoreStatus): + """ + update_status is called from the data store to push a status update. + """ + self.__lock.lock() + modified = False + + if self.__status != status: + self.__status = status + modified = True + + self.__lock.unlock() + + if modified: + self.__listeners.notify(status) + + @property + def status(self) -> DataStoreStatus: + self.__lock.rlock() + status = copy(self.__status) + self.__lock.runlock() + + return status + + def is_monitoring_enabled(self) -> bool: + if self.__store is None: + return False + if hasattr(self.__store, "is_monitoring_enabled") is False: + return False + + return self.__store.is_monitoring_enabled() # type: ignore + + def add_listener(self, listener: Callable[[DataStoreStatus], None]): + self.__listeners.add(listener) + + def remove_listener(self, listener: Callable[[DataStoreStatus], None]): + self.__listeners.remove(listener) diff --git a/ldclient/impl/datasystem/config.py b/ldclient/impl/datasystem/config.py index e9c42efd..d2755865 100644 --- a/ldclient/impl/datasystem/config.py +++ b/ldclient/impl/datasystem/config.py @@ -16,6 +16,7 @@ StreamingDataSourceBuilder ) from ldclient.impl.datasystem import Initializer, Synchronizer +from ldclient.interfaces import DataStoreMode, FeatureStore T = TypeVar("T") @@ -30,6 +31,8 @@ class ConfigBuilder: # pylint: disable=too-few-public-methods _initializers: Optional[List[Builder[Initializer]]] = None _primary_synchronizer: Optional[Builder[Synchronizer]] = None _secondary_synchronizer: Optional[Builder[Synchronizer]] = None + _store_mode: DataStoreMode = DataStoreMode.READ_ONLY + _data_store: Optional[FeatureStore] = None def initializers(self, initializers: Optional[List[Builder[Initializer]]]) -> "ConfigBuilder": """ @@ -50,17 +53,27 @@ def synchronizers( self._secondary_synchronizer = secondary return self + def data_store(self, data_store: FeatureStore, store_mode: DataStoreMode) -> "ConfigBuilder": + """ + Sets the data store configuration for the data system. + """ + self._data_store = data_store + self._store_mode = store_mode + return self + def build(self) -> DataSystemConfig: """ Builds the data system configuration. """ - if self._primary_synchronizer is None: - raise ValueError("Primary synchronizer must be set") + if self._secondary_synchronizer is not None and self._primary_synchronizer is None: + raise ValueError("Primary synchronizer must be set if secondary is set") return DataSystemConfig( initializers=self._initializers, primary_synchronizer=self._primary_synchronizer, secondary_synchronizer=self._secondary_synchronizer, + data_store_mode=self._store_mode, + data_store=self._data_store, ) @@ -147,18 +160,29 @@ def custom() -> ConfigBuilder: return ConfigBuilder() -# TODO(fdv2): Implement these methods -# -# Daemon configures the SDK to read from a persistent store integration -# that is populated by Relay Proxy or other SDKs. The SDK will not connect -# to LaunchDarkly. In this mode, the SDK never writes to the data store. +# TODO(fdv2): Need to update these so they don't rely on the LDConfig +def daemon(config: LDConfig, store: FeatureStore) -> ConfigBuilder: + """ + Daemon configures the SDK to read from a persistent store integration + that is populated by Relay Proxy or other SDKs. The SDK will not connect + to LaunchDarkly. In this mode, the SDK never writes to the data store. + """ + return default(config).data_store(store, DataStoreMode.READ_ONLY) + -# PersistentStore is similar to Default, with the addition of a persistent -# store integration. Before data has arrived from LaunchDarkly, the SDK is -# able to evaluate flags using data from the persistent store. Once fresh -# data is available, the SDK will no longer read from the persistent store, -# although it will keep it up-to-date. +def persistent_store(config: LDConfig, store: FeatureStore) -> ConfigBuilder: + """ + PersistentStore is similar to Default, with the addition of a persistent + store integration. Before data has arrived from LaunchDarkly, the SDK is + able to evaluate flags using data from the persistent store. Once fresh + data is available, the SDK will no longer read from the persistent store, + although it will keep it up-to-date. + """ + return default(config).data_store(store, DataStoreMode.READ_WRITE) + +# TODO(fdv2): Implement these methods +# # WithEndpoints configures the data system with custom endpoints for # LaunchDarkly's streaming and polling synchronizers. This method is not # necessary for most use-cases, but can be useful for testing or custom diff --git a/ldclient/impl/datasystem/fdv2.py b/ldclient/impl/datasystem/fdv2.py index cfb61750..3106074f 100644 --- a/ldclient/impl/datasystem/fdv2.py +++ b/ldclient/impl/datasystem/fdv2.py @@ -1,22 +1,147 @@ +import logging import time from threading import Event, Thread -from typing import Callable, List, Optional +from typing import Any, Callable, Dict, List, Mapping, Optional from ldclient.config import Builder, DataSystemConfig -from ldclient.impl.datasourcev2.status import DataSourceStatusProviderImpl +from ldclient.feature_store import _FeatureStoreDataSetSorter +from ldclient.impl.datasourcev2.status import ( + DataSourceStatusProviderImpl, + DataStoreStatusProviderImpl +) from ldclient.impl.datasystem import DataAvailability, Synchronizer from ldclient.impl.datasystem.store import Store from ldclient.impl.flag_tracker import FlagTrackerImpl from ldclient.impl.listeners import Listeners -from ldclient.impl.util import _Fail +from ldclient.impl.repeating_task import RepeatingTask +from ldclient.impl.rwlock import ReadWriteLock +from ldclient.impl.util import _Fail, log from ldclient.interfaces import ( DataSourceState, DataSourceStatus, DataSourceStatusProvider, + DataStoreMode, + DataStoreStatus, DataStoreStatusProvider, FeatureStore, FlagTracker ) +from ldclient.versioned_data_kind import VersionedDataKind + + +class FeatureStoreClientWrapper(FeatureStore): + """Provides additional behavior that the client requires before or after feature store operations. + Currently this just means sorting the data set for init() and dealing with data store status listeners. + """ + + def __init__(self, store: FeatureStore, store_update_sink: DataStoreStatusProviderImpl): + self.store = store + self.__store_update_sink = store_update_sink + self.__monitoring_enabled = self.is_monitoring_enabled() + + # Covers the following variables + self.__lock = ReadWriteLock() + self.__last_available = True + self.__poller: Optional[RepeatingTask] = None + + def init(self, all_data: Mapping[VersionedDataKind, Mapping[str, Dict[Any, Any]]]): + return self.__wrapper(lambda: self.store.init(_FeatureStoreDataSetSorter.sort_all_collections(all_data))) + + def get(self, kind, key, callback): + return self.__wrapper(lambda: self.store.get(kind, key, callback)) + + def all(self, kind, callback): + return self.__wrapper(lambda: self.store.all(kind, callback)) + + def delete(self, kind, key, version): + return self.__wrapper(lambda: self.store.delete(kind, key, version)) + + def upsert(self, kind, item): + return self.__wrapper(lambda: self.store.upsert(kind, item)) + + @property + def initialized(self) -> bool: + return self.store.initialized + + def __wrapper(self, fn: Callable): + try: + return fn() + except BaseException: + if self.__monitoring_enabled: + self.__update_availability(False) + raise + + def __update_availability(self, available: bool): + try: + self.__lock.lock() + if available == self.__last_available: + return + self.__last_available = available + finally: + self.__lock.unlock() + + if available: + log.warning("Persistent store is available again") + + status = DataStoreStatus(available, False) + self.__store_update_sink.update_status(status) + + if available: + try: + self.__lock.lock() + if self.__poller is not None: + self.__poller.stop() + self.__poller = None + finally: + self.__lock.unlock() + + return + + log.warning("Detected persistent store unavailability; updates will be cached until it recovers") + task = RepeatingTask("ldclient.check-availability", 0.5, 0, self.__check_availability) + + self.__lock.lock() + self.__poller = task + self.__poller.start() + self.__lock.unlock() + + def __check_availability(self): + try: + if self.store.is_available(): + self.__update_availability(True) + except BaseException as e: + log.error("Unexpected error from data store status function: %s", e) + + def is_monitoring_enabled(self) -> bool: + """ + This methods determines whether the wrapped store can support enabling monitoring. + + The wrapped store must provide a monitoring_enabled method, which must + be true. But this alone is not sufficient. + + Because this class wraps all interactions with a provided store, it can + technically "monitor" any store. However, monitoring also requires that + we notify listeners when the store is available again. + + We determine this by checking the store's `available?` method, so this + is also a requirement for monitoring support. + + These extra checks won't be necessary once `available` becomes a part + of the core interface requirements and this class no longer wraps every + feature store. + """ + + if not hasattr(self.store, 'is_monitoring_enabled'): + return False + + if not hasattr(self.store, 'is_available'): + return False + + monitoring_enabled = getattr(self.store, 'is_monitoring_enabled') + if not callable(monitoring_enabled): + return False + + return monitoring_enabled() class FDv2: @@ -29,9 +154,6 @@ class FDv2: def __init__( self, config: DataSystemConfig, - # # TODO: These next 2 parameters should be moved into the Config. - # persistent_store: Optional[FeatureStore] = None, - # store_writable: bool = True, disabled: bool = False, ): """ @@ -56,19 +178,24 @@ def __init__( # Set up event listeners self._flag_change_listeners = Listeners() self._change_set_listeners = Listeners() + self._data_store_listeners = Listeners() # Create the store self._store = Store(self._flag_change_listeners, self._change_set_listeners) # Status providers self._data_source_status_provider = DataSourceStatusProviderImpl(Listeners()) + self._data_store_status_provider = DataStoreStatusProviderImpl(None, Listeners()) + + # Configure persistent store if provided + if self._config.data_store is not None: + self._data_store_status_provider = DataStoreStatusProviderImpl(self._config.data_store, Listeners()) + writable = self._config.data_store_mode == DataStoreMode.READ_WRITE + wrapper = FeatureStoreClientWrapper(self._config.data_store, self._data_store_status_provider) + self._store.with_persistence( + wrapper, writable, self._data_store_status_provider + ) - # # Configure persistent store if provided - # if persistent_store is not None: - # self._store.with_persistence( - # persistent_store, store_writable, self._data_source_status_provider - # ) - # # Flag tracker (evaluation function set later by client) self._flag_tracker = FlagTrackerImpl( self._flag_change_listeners, @@ -80,8 +207,6 @@ def __init__( self._threads: List[Thread] = [] # Track configuration - # TODO: What is the point of checking if primary_synchronizer is not - # None? Doesn't it have to be set? self._configured_with_data_sources = ( (config.initializers is not None and len(config.initializers) > 0) or config.primary_synchronizer is not None @@ -94,7 +219,7 @@ def start(self, set_on_ready: Event): :param set_on_ready: Event to set when the system is ready or has failed """ if self._disabled: - print("Data system is disabled, SDK will return application-defined default values") + log.warning("Data system is disabled, SDK will return application-defined default values") set_on_ready.set() return @@ -139,25 +264,11 @@ def _run_main_loop(self, set_on_ready: Event): # Run initializers first self._run_initializers(set_on_ready) - # # If we have persistent store with status monitoring, start recovery monitoring - # if ( - # self._configured_with_data_sources - # and self._data_store_status_provider is not None - # and hasattr(self._data_store_status_provider, 'add_listener') - # ): - # recovery_thread = Thread( - # target=self._run_persistent_store_outage_recovery, - # name="FDv2-store-recovery", - # daemon=True - # ) - # recovery_thread.start() - # self._threads.append(recovery_thread) - # Run synchronizers self._run_synchronizers(set_on_ready) except Exception as e: - print(f"Error in FDv2 main loop: {e}") + log.error(f"Error in FDv2 main loop: {e}") # Ensure ready event is set even on error if not set_on_ready.is_set(): set_on_ready.set() @@ -173,16 +284,16 @@ def _run_initializers(self, set_on_ready: Event): try: initializer = initializer_builder() - print(f"Attempting to initialize via {initializer.name}") + log.info(f"Attempting to initialize via {initializer.name}") basis_result = initializer.fetch() if isinstance(basis_result, _Fail): - print(f"Initializer {initializer.name} failed: {basis_result.error}") + log.warning(f"Initializer {initializer.name} failed: {basis_result.error}") continue basis = basis_result.value - print(f"Initialized via {initializer.name}") + log.info(f"Initialized via {initializer.name}") # Apply the basis to the store self._store.apply(basis.change_set, basis.persist) @@ -191,7 +302,7 @@ def _run_initializers(self, set_on_ready: Event): if not set_on_ready.is_set(): set_on_ready.set() except Exception as e: - print(f"Initializer failed with exception: {e}") + log.error(f"Initializer failed with exception: {e}") def _run_synchronizers(self, set_on_ready: Event): """Run synchronizers to keep data up-to-date.""" @@ -208,7 +319,7 @@ def synchronizer_loop(self: 'FDv2'): # Try primary synchronizer try: primary_sync = self._primary_synchronizer_builder() - print(f"Primary synchronizer {primary_sync.name} is starting") + log.info(f"Primary synchronizer {primary_sync.name} is starting") remove_sync, fallback_v1 = self._consume_synchronizer_results( primary_sync, set_on_ready, self._fallback_condition @@ -222,20 +333,20 @@ def synchronizer_loop(self: 'FDv2'): self._primary_synchronizer_builder = self._fdv1_fallback_synchronizer_builder if self._primary_synchronizer_builder is None: - print("No more synchronizers available") + log.warning("No more synchronizers available") self._data_source_status_provider.update_status( DataSourceState.OFF, self._data_source_status_provider.status.error ) break else: - print("Fallback condition met") + log.info("Fallback condition met") if self._secondary_synchronizer_builder is None: continue secondary_sync = self._secondary_synchronizer_builder() - print(f"Secondary synchronizer {secondary_sync.name} is starting") + log.info(f"Secondary synchronizer {secondary_sync.name} is starting") remove_sync, fallback_v1 = self._consume_synchronizer_results( secondary_sync, set_on_ready, self._recovery_condition @@ -247,7 +358,7 @@ def synchronizer_loop(self: 'FDv2'): self._primary_synchronizer_builder = self._fdv1_fallback_synchronizer_builder if self._primary_synchronizer_builder is None: - print("No more synchronizers available") + log.warning("No more synchronizers available") self._data_source_status_provider.update_status( DataSourceState.OFF, self._data_source_status_provider.status.error @@ -255,13 +366,13 @@ def synchronizer_loop(self: 'FDv2'): # TODO: WE might need to also set that threading.Event here break - print("Recovery condition met, returning to primary synchronizer") + log.info("Recovery condition met, returning to primary synchronizer") except Exception as e: - print(f"Failed to build primary synchronizer: {e}") + log.error(f"Failed to build primary synchronizer: {e}") break except Exception as e: - print(f"Error in synchronizer loop: {e}") + log.error(f"Error in synchronizer loop: {e}") finally: # Ensure we always set the ready event when exiting if not set_on_ready.is_set(): @@ -289,7 +400,7 @@ def _consume_synchronizer_results( """ try: for update in synchronizer.sync(): - print(f"Synchronizer {synchronizer.name} update: {update.state}") + log.info(f"Synchronizer {synchronizer.name} update: {update.state}") if self._stop_event.is_set(): return False, False @@ -314,18 +425,11 @@ def _consume_synchronizer_results( return False, False except Exception as e: - print(f"Error consuming synchronizer results: {e}") + log.error(f"Error consuming synchronizer results: {e}") return True, False return True, False - # def _run_persistent_store_outage_recovery(self): - # """Monitor persistent store status and trigger recovery when needed.""" - # # This is a simplified version - in a full implementation we'd need - # # to properly monitor store status and trigger commit operations - # # when the store comes back online after an outage - # pass - # def _fallback_condition(self, status: DataSourceStatus) -> bool: """ Determine if we should fallback to secondary synchronizer. @@ -387,8 +491,7 @@ def data_source_status_provider(self) -> DataSourceStatusProvider: @property def data_store_status_provider(self) -> DataStoreStatusProvider: """Get the data store status provider.""" - raise NotImplementedError - # return self._data_store_status_provider + return self._data_store_status_provider @property def flag_tracker(self) -> FlagTracker: diff --git a/ldclient/impl/datasystem/store.py b/ldclient/impl/datasystem/store.py index 435a0faf..94f015e7 100644 --- a/ldclient/impl/datasystem/store.py +++ b/ldclient/impl/datasystem/store.py @@ -20,6 +20,7 @@ ) from ldclient.impl.dependency_tracker import DependencyTracker, KindAndKey from ldclient.impl.listeners import Listeners +from ldclient.impl.util import log from ldclient.interfaces import ( DataStoreStatusProvider, FeatureStore, @@ -144,7 +145,7 @@ def apply(self, change_set: ChangeSet, persist: bool) -> None: except Exception as e: # Log error but don't re-raise - matches Go behavior - print(f"Store: couldn't apply changeset: {e}") + log.error(f"Store: couldn't apply changeset: {e}") def _set_basis(self, change_set: ChangeSet, persist: bool) -> None: """ diff --git a/ldclient/interfaces.py b/ldclient/interfaces.py index 86a023fa..cae5c237 100644 --- a/ldclient/interfaces.py +++ b/ldclient/interfaces.py @@ -14,6 +14,31 @@ from .versioned_data_kind import VersionedDataKind +class DataStoreMode(Enum): + """ + DataStoreMode represents the mode of operation of a Data Store in FDV2 + mode. + + This enum is not stable, and not subject to any backwards compatibility + guarantees or semantic versioning. It is not suitable for production usage. + + Do not use it. + You have been warned. + """ + + READ_ONLY = 'read-only' + """ + READ_ONLY indicates that the data store is read-only. Data will never be + written back to the store by the SDK. + """ + + READ_WRITE = 'read-write' + """ + READ_WRITE indicates that the data store is read-write. Data from + initializers/synchronizers may be written to the store as necessary. + """ + + class FeatureStore: """ Interface for a versioned store for feature flags and related objects received from LaunchDarkly. @@ -923,8 +948,8 @@ class DataStoreStatus: __metaclass__ = ABCMeta def __init__(self, available: bool, stale: bool): - self.__available = available - self.__stale = stale + self._available = available + self._stale = stale @property def available(self) -> bool: @@ -939,7 +964,7 @@ def available(self) -> bool: :return: if store is available """ - return self.__available + return self._available @property def stale(self) -> bool: @@ -952,7 +977,18 @@ def stale(self) -> bool: :return: true if data should be rewritten """ - return self.__stale + return self._stale + + def __eq__(self, other): + """ + Ensures two instances of DataStoreStatus are the same if their properties are the same. + + :param other: The other instance to compare + :return: True if instances are equal, False otherwise + """ + if isinstance(other, DataStoreStatus): + return self._available == other._available and self._stale == other._stale + return False class DataStoreUpdateSink: diff --git a/ldclient/testing/impl/datasystem/test_config.py b/ldclient/testing/impl/datasystem/test_config.py index db73aece..5142fb82 100644 --- a/ldclient/testing/impl/datasystem/test_config.py +++ b/ldclient/testing/impl/datasystem/test_config.py @@ -68,25 +68,6 @@ def test_config_builder_build_success(): assert config.secondary_synchronizer == mock_secondary -def test_config_builder_build_missing_primary_synchronizer(): - """Test that build fails when primary synchronizer is not set.""" - builder = ConfigBuilder() - - with pytest.raises(ValueError, match="Primary synchronizer must be set"): - builder.build() - - -def test_config_builder_build_with_initializers_only(): - """Test that build fails when only initializers are set.""" - builder = ConfigBuilder() - mock_initializer = Mock() - - builder.initializers([mock_initializer]) - - with pytest.raises(ValueError, match="Primary synchronizer must be set"): - builder.build() - - def test_config_builder_method_chaining(): """Test that all builder methods support method chaining.""" builder = ConfigBuilder() diff --git a/ldclient/testing/impl/datasystem/test_fdv2_persistence.py b/ldclient/testing/impl/datasystem/test_fdv2_persistence.py new file mode 100644 index 00000000..c64757ab --- /dev/null +++ b/ldclient/testing/impl/datasystem/test_fdv2_persistence.py @@ -0,0 +1,524 @@ +# pylint: disable=missing-docstring + +from threading import Event +from typing import Any, Callable, Dict, List, Mapping, Optional + +from ldclient.config import DataSystemConfig +from ldclient.impl.datasystem import DataAvailability +from ldclient.impl.datasystem.fdv2 import FDv2 +from ldclient.integrations.test_datav2 import TestDataV2 +from ldclient.interfaces import ( + DataStoreMode, + DataStoreStatus, + FeatureStore, + FlagChange +) +from ldclient.versioned_data_kind import FEATURES, SEGMENTS, VersionedDataKind + + +class StubFeatureStore(FeatureStore): + """ + A simple stub implementation of FeatureStore for testing. + Records all operations and allows inspection of state. + """ + def __init__(self, initial_data: Optional[Mapping[VersionedDataKind, Mapping[str, Dict[Any, Any]]]] = None): + self._data: Dict[VersionedDataKind, Dict[str, dict]] = { + FEATURES: {}, + SEGMENTS: {} + } + self._initialized = False + self._available = True + self._monitoring_enabled = False + + # Track operations for assertions + self.init_called_count = 0 + self.upsert_calls: List[tuple] = [] + self.delete_calls: List[tuple] = [] + self.get_calls: List[tuple] = [] + self.all_calls: List[VersionedDataKind] = [] + + if initial_data: + self.init(initial_data) + + def init(self, all_data: Mapping[VersionedDataKind, Mapping[str, Dict[Any, Any]]]): + self.init_called_count += 1 + self._data = { + FEATURES: dict(all_data.get(FEATURES, {})), + SEGMENTS: dict(all_data.get(SEGMENTS, {})) + } + self._initialized = True + + def get(self, kind: VersionedDataKind, key: str, callback: Callable[[Any], Any] = lambda x: x): + self.get_calls.append((kind, key)) + item = self._data.get(kind, {}).get(key) + return callback(item) if item else None + + def all(self, kind: VersionedDataKind, callback: Callable[[Any], Any] = lambda x: x): + self.all_calls.append(kind) + items = self._data.get(kind, {}) + return {key: callback(value) for key, value in items.items()} + + def delete(self, kind: VersionedDataKind, key: str, version: int): + self.delete_calls.append((kind, key, version)) + existing = self._data.get(kind, {}).get(key) + if existing and existing.get('version', 0) < version: + self._data[kind][key] = {'key': key, 'version': version, 'deleted': True} + + def upsert(self, kind: VersionedDataKind, item: dict): + self.upsert_calls.append((kind, item.get('key'), item.get('version'))) + key = item['key'] + existing = self._data.get(kind, {}).get(key) + if not existing or existing.get('version', 0) < item.get('version', 0): + self._data[kind][key] = item + + @property + def initialized(self) -> bool: + return self._initialized + + def is_available(self) -> bool: + """For monitoring support""" + return self._available + + def is_monitoring_enabled(self) -> bool: + """For monitoring support""" + return self._monitoring_enabled + + def set_available(self, available: bool): + """Test helper to simulate availability changes""" + self._available = available + + def enable_monitoring(self): + """Test helper to enable monitoring""" + self._monitoring_enabled = True + + def get_data_snapshot(self) -> Mapping[VersionedDataKind, Mapping[str, dict]]: + """Test helper to get a snapshot of current data""" + return { + FEATURES: dict(self._data[FEATURES]), + SEGMENTS: dict(self._data[SEGMENTS]) + } + + def reset_operation_tracking(self): + """Test helper to reset operation tracking""" + self.init_called_count = 0 + self.upsert_calls = [] + self.delete_calls = [] + self.get_calls = [] + self.all_calls = [] + + +def test_persistent_store_read_only_mode(): + """Test that READ_ONLY mode reads from store but never writes""" + # Pre-populate persistent store with a flag + initial_data = { + FEATURES: { + 'existing-flag': { + 'key': 'existing-flag', + 'version': 1, + 'on': True, + 'variations': [True, False], + 'fallthrough': {'variation': 0} + } + }, + SEGMENTS: {} + } + + persistent_store = StubFeatureStore(initial_data) + + # Create synchronizer that will provide new data + td_synchronizer = TestDataV2.data_source() + td_synchronizer.update(td_synchronizer.flag("new-flag").on(True)) + + config = DataSystemConfig( + data_store_mode=DataStoreMode.READ_ONLY, + data_store=persistent_store, + initializers=None, + primary_synchronizer=td_synchronizer.build_synchronizer, + ) + + set_on_ready = Event() + fdv2 = FDv2(config) + fdv2.start(set_on_ready) + + assert set_on_ready.wait(1), "Data system did not become ready in time" + + # Verify data system is initialized and available + assert fdv2.data_availability.at_least(DataAvailability.REFRESHED) + + # Verify the store was initialized once (by us) but no additional writes happened + # The persistent store should have been read from, but not written to + assert persistent_store.init_called_count == 1 # Only our initial setup + assert len(persistent_store.upsert_calls) == 0 # No upserts in READ_ONLY mode + + fdv2.stop() + + +def test_persistent_store_read_write_mode(): + """Test that READ_WRITE mode reads from store and writes updates back""" + # Pre-populate persistent store with a flag + initial_data = { + FEATURES: { + 'existing-flag': { + 'key': 'existing-flag', + 'version': 1, + 'on': True, + 'variations': [True, False], + 'fallthrough': {'variation': 0} + } + }, + SEGMENTS: {} + } + + persistent_store = StubFeatureStore(initial_data) + persistent_store.reset_operation_tracking() # Reset tracking after initial setup + + # Create synchronizer that will provide new data + td_synchronizer = TestDataV2.data_source() + td_synchronizer.update(td_synchronizer.flag("new-flag").on(True)) + + config = DataSystemConfig( + data_store_mode=DataStoreMode.READ_WRITE, + data_store=persistent_store, + initializers=None, + primary_synchronizer=td_synchronizer.build_synchronizer, + ) + + set_on_ready = Event() + fdv2 = FDv2(config) + fdv2.start(set_on_ready) + + assert set_on_ready.wait(1), "Data system did not become ready in time" + + # In READ_WRITE mode, the store should be initialized with new data + assert persistent_store.init_called_count >= 1 # At least one init call for the new data + + # Verify the new flag was written to persistent store + snapshot = persistent_store.get_data_snapshot() + assert 'new-flag' in snapshot[FEATURES] + + fdv2.stop() + + +def test_persistent_store_delta_updates_read_write(): + """Test that delta updates are written to persistent store in READ_WRITE mode""" + persistent_store = StubFeatureStore() + + # Create synchronizer + td_synchronizer = TestDataV2.data_source() + td_synchronizer.update(td_synchronizer.flag("feature-flag").on(True)) + + config = DataSystemConfig( + data_store_mode=DataStoreMode.READ_WRITE, + data_store=persistent_store, + initializers=None, + primary_synchronizer=td_synchronizer.build_synchronizer, + ) + + set_on_ready = Event() + fdv2 = FDv2(config) + + # Set up flag change listener to detect the update + flag_changed = Event() + change_count = [0] # Use list to allow modification in nested function + + def listener(flag_change: FlagChange): + change_count[0] += 1 + if change_count[0] == 2: # First change is from initial sync, second is our update + flag_changed.set() + + fdv2.flag_tracker.add_listener(listener) + fdv2.start(set_on_ready) + + assert set_on_ready.wait(1), "Data system did not become ready in time" + + persistent_store.reset_operation_tracking() + + # Make a delta update + td_synchronizer.update(td_synchronizer.flag("feature-flag").on(False)) + + # Wait for the flag change to propagate + assert flag_changed.wait(1), "Flag change did not propagate in time" + + # Verify the update was written to persistent store + assert len(persistent_store.upsert_calls) > 0 + assert any(call[1] == 'feature-flag' for call in persistent_store.upsert_calls) + + # Verify the updated flag is in the store + snapshot = persistent_store.get_data_snapshot() + assert 'feature-flag' in snapshot[FEATURES] + assert snapshot[FEATURES]['feature-flag']['on'] is False + + fdv2.stop() + + +def test_persistent_store_delta_updates_read_only(): + """Test that delta updates are NOT written to persistent store in READ_ONLY mode""" + persistent_store = StubFeatureStore() + + # Create synchronizer + td_synchronizer = TestDataV2.data_source() + td_synchronizer.update(td_synchronizer.flag("feature-flag").on(True)) + + config = DataSystemConfig( + data_store_mode=DataStoreMode.READ_ONLY, + data_store=persistent_store, + initializers=None, + primary_synchronizer=td_synchronizer.build_synchronizer, + ) + + set_on_ready = Event() + fdv2 = FDv2(config) + + # Set up flag change listener to detect the update + flag_changed = Event() + change_count = [0] # Use list to allow modification in nested function + + def listener(flag_change: FlagChange): + change_count[0] += 1 + if change_count[0] == 2: # First change is from initial sync, second is our update + flag_changed.set() + + fdv2.flag_tracker.add_listener(listener) + fdv2.start(set_on_ready) + + assert set_on_ready.wait(1), "Data system did not become ready in time" + + persistent_store.reset_operation_tracking() + + # Make a delta update + td_synchronizer.update(td_synchronizer.flag("feature-flag").on(False)) + + # Wait for the flag change to propagate + assert flag_changed.wait(1), "Flag change did not propagate in time" + + # Verify NO updates were written to persistent store in READ_ONLY mode + assert len(persistent_store.upsert_calls) == 0 + + fdv2.stop() + + +def test_persistent_store_with_initializer_and_synchronizer(): + """Test that both initializer and synchronizer data are persisted in READ_WRITE mode""" + persistent_store = StubFeatureStore() + + # Create initializer with one flag + td_initializer = TestDataV2.data_source() + td_initializer.update(td_initializer.flag("init-flag").on(True)) + + # Create synchronizer with another flag + td_synchronizer = TestDataV2.data_source() + td_synchronizer.update(td_synchronizer.flag("sync-flag").on(False)) + + config = DataSystemConfig( + data_store_mode=DataStoreMode.READ_WRITE, + data_store=persistent_store, + initializers=[td_initializer.build_initializer], + primary_synchronizer=td_synchronizer.build_synchronizer, + ) + + set_on_ready = Event() + fdv2 = FDv2(config) + + # Set up flag change listener to detect when synchronizer data arrives + sync_flag_arrived = Event() + + def listener(flag_change: FlagChange): + if flag_change.key == "sync-flag": + sync_flag_arrived.set() + + fdv2.flag_tracker.add_listener(listener) + fdv2.start(set_on_ready) + + assert set_on_ready.wait(1), "Data system did not become ready in time" + + # Wait for synchronizer to fully initialize + # The synchronizer does a full data set transfer, so it replaces the initializer data + assert sync_flag_arrived.wait(1), "Synchronizer data did not arrive in time" + + # The synchronizer flag should be in the persistent store + # (it replaces the init-flag since synchronizer does a full data set) + snapshot = persistent_store.get_data_snapshot() + assert 'init-flag' not in snapshot[FEATURES] + assert 'sync-flag' in snapshot[FEATURES] + + fdv2.stop() + + +def test_persistent_store_delete_operations(): + """Test that delete operations are written to persistent store in READ_WRITE mode""" + # We'll need to manually trigger a delete via the store + # This is more of an integration test with the Store class + from ldclient.impl.datasystem.protocolv2 import ( + Change, + ChangeSet, + ChangeType, + IntentCode, + ObjectKind + ) + from ldclient.impl.datasystem.store import Store + from ldclient.impl.listeners import Listeners + + # Pre-populate with a flag + initial_data = { + FEATURES: { + 'deletable-flag': { + 'key': 'deletable-flag', + 'version': 1, + 'on': True, + 'variations': [True, False], + 'fallthrough': {'variation': 0} + } + }, + SEGMENTS: {} + } + + persistent_store = StubFeatureStore(initial_data) + + store = Store(Listeners(), Listeners()) + store.with_persistence(persistent_store, True, None) + + # First, initialize the store with the data so it's in memory + init_changeset = ChangeSet( + intent_code=IntentCode.TRANSFER_FULL, + changes=[ + Change( + action=ChangeType.PUT, + kind=ObjectKind.FLAG, + key='deletable-flag', + version=1, + object={ + 'key': 'deletable-flag', + 'version': 1, + 'on': True, + 'variations': [True, False], + 'fallthrough': {'variation': 0} + } + ) + ], + selector=None + ) + store.apply(init_changeset, True) + + persistent_store.reset_operation_tracking() + + # Now apply a changeset with a delete + delete_changeset = ChangeSet( + intent_code=IntentCode.TRANSFER_CHANGES, + changes=[ + Change( + action=ChangeType.DELETE, + kind=ObjectKind.FLAG, + key='deletable-flag', + version=2, + object=None + ) + ], + selector=None + ) + + store.apply(delete_changeset, True) + + # Verify delete was called on persistent store + assert len(persistent_store.delete_calls) > 0 + assert any(call[1] == 'deletable-flag' for call in persistent_store.delete_calls) + + +def test_data_store_status_provider(): + """Test that data store status provider is correctly initialized""" + persistent_store = StubFeatureStore() + + td_synchronizer = TestDataV2.data_source() + td_synchronizer.update(td_synchronizer.flag("feature-flag").on(True)) + + config = DataSystemConfig( + data_store_mode=DataStoreMode.READ_WRITE, + data_store=persistent_store, + initializers=None, + primary_synchronizer=td_synchronizer.build_synchronizer, + ) + + set_on_ready = Event() + fdv2 = FDv2(config) + + # Verify data store status provider exists + status_provider = fdv2.data_store_status_provider + assert status_provider is not None + + # Get initial status + status = status_provider.status + assert status is not None + assert status.available is True + + fdv2.start(set_on_ready) + assert set_on_ready.wait(1), "Data system did not become ready in time" + + fdv2.stop() + + +def test_data_store_status_monitoring_not_enabled_by_default(): + """Test that monitoring is not enabled by default""" + persistent_store = StubFeatureStore() + + td_synchronizer = TestDataV2.data_source() + td_synchronizer.update(td_synchronizer.flag("feature-flag").on(True)) + + config = DataSystemConfig( + data_store_mode=DataStoreMode.READ_WRITE, + data_store=persistent_store, + initializers=None, + primary_synchronizer=td_synchronizer.build_synchronizer, + ) + + fdv2 = FDv2(config) + + # Monitoring should not be enabled because the store doesn't support it + status_provider = fdv2.data_store_status_provider + assert status_provider.is_monitoring_enabled() is False + + +def test_data_store_status_monitoring_enabled_when_supported(): + """Test that monitoring is enabled when the store supports it""" + persistent_store = StubFeatureStore() + persistent_store.enable_monitoring() + + td_synchronizer = TestDataV2.data_source() + td_synchronizer.update(td_synchronizer.flag("feature-flag").on(True)) + + config = DataSystemConfig( + data_store_mode=DataStoreMode.READ_WRITE, + data_store=persistent_store, + initializers=None, + primary_synchronizer=td_synchronizer.build_synchronizer, + ) + + fdv2 = FDv2(config) + + # Monitoring should be enabled + status_provider = fdv2.data_store_status_provider + assert status_provider.is_monitoring_enabled() is True + + +def test_no_persistent_store_status_provider_without_store(): + """Test that data store status provider exists even without a persistent store""" + td_synchronizer = TestDataV2.data_source() + td_synchronizer.update(td_synchronizer.flag("feature-flag").on(True)) + + config = DataSystemConfig( + data_store_mode=DataStoreMode.READ_WRITE, + data_store=None, + initializers=None, + primary_synchronizer=td_synchronizer.build_synchronizer, + ) + + set_on_ready = Event() + fdv2 = FDv2(config) + + # Status provider should exist but not be monitoring + status_provider = fdv2.data_store_status_provider + assert status_provider is not None + assert status_provider.is_monitoring_enabled() is False + + fdv2.start(set_on_ready) + assert set_on_ready.wait(1), "Data system did not become ready in time" + + fdv2.stop() From 3858a2bce9556366fa83f30dd58de8db029d140b Mon Sep 17 00:00:00 2001 From: "Matthew M. Keeler" Date: Thu, 30 Oct 2025 16:40:27 -0400 Subject: [PATCH 03/13] chore: Remove LDConfig requirement from top level DS function helpers (#358) --- ldclient/client.py | 2 +- ldclient/config.py | 2 +- ldclient/impl/datasystem/config.py | 33 ++-- ldclient/impl/datasystem/fdv2.py | 61 +++---- ldclient/integrations/test_datav2.py | 5 +- .../testing/impl/datasystem/test_config.py | 12 +- .../impl/datasystem/test_fdv2_datasystem.py | 28 +-- .../impl/datasystem/test_fdv2_persistence.py | 170 ++++++++++-------- .../integrations/test_test_data_sourcev2.py | 21 +-- 9 files changed, 172 insertions(+), 162 deletions(-) diff --git a/ldclient/client.py b/ldclient/client.py index 6c3269ad..71158291 100644 --- a/ldclient/client.py +++ b/ldclient/client.py @@ -260,7 +260,7 @@ def __start_up(self, start_wait: float): self._data_system: DataSystem = FDv1(self._config) else: - self._data_system = FDv2(datasystem_config, disabled=self._config.offline) + self._data_system = FDv2(self._config, datasystem_config) # Provide flag evaluation function for value-change tracking self._data_system.set_flag_value_eval_fn( # type: ignore diff --git a/ldclient/config.py b/ldclient/config.py index 01e12fec..7d4a7901 100644 --- a/ldclient/config.py +++ b/ldclient/config.py @@ -157,7 +157,7 @@ def disable_ssl_verification(self) -> bool: T = TypeVar("T") -Builder = Callable[[], T] +Builder = Callable[['Config'], T] @dataclass(frozen=True) diff --git a/ldclient/impl/datasystem/config.py b/ldclient/impl/datasystem/config.py index d2755865..c02ba952 100644 --- a/ldclient/impl/datasystem/config.py +++ b/ldclient/impl/datasystem/config.py @@ -20,7 +20,7 @@ T = TypeVar("T") -Builder = Callable[[], T] +Builder = Callable[[LDConfig], T] class ConfigBuilder: # pylint: disable=too-few-public-methods @@ -77,8 +77,8 @@ def build(self) -> DataSystemConfig: ) -def __polling_ds_builder(config: LDConfig) -> Builder[PollingDataSource]: - def builder() -> PollingDataSource: +def __polling_ds_builder() -> Builder[PollingDataSource]: + def builder(config: LDConfig) -> PollingDataSource: requester = Urllib3PollingRequester(config) polling_ds = PollingDataSourceBuilder(config) polling_ds.requester(requester) @@ -88,14 +88,14 @@ def builder() -> PollingDataSource: return builder -def __streaming_ds_builder(config: LDConfig) -> Builder[StreamingDataSource]: - def builder() -> StreamingDataSource: +def __streaming_ds_builder() -> Builder[StreamingDataSource]: + def builder(config: LDConfig) -> StreamingDataSource: return StreamingDataSourceBuilder(config).build() return builder -def default(config: LDConfig) -> ConfigBuilder: +def default() -> ConfigBuilder: """ Default is LaunchDarkly's recommended flag data acquisition strategy. @@ -109,8 +109,8 @@ def default(config: LDConfig) -> ConfigBuilder: for updates. """ - polling_builder = __polling_ds_builder(config) - streaming_builder = __streaming_ds_builder(config) + polling_builder = __polling_ds_builder() + streaming_builder = __streaming_ds_builder() builder = ConfigBuilder() builder.initializers([polling_builder]) @@ -119,14 +119,14 @@ def default(config: LDConfig) -> ConfigBuilder: return builder -def streaming(config: LDConfig) -> ConfigBuilder: +def streaming() -> ConfigBuilder: """ Streaming configures the SDK to efficiently streams flag/segment data in the background, allowing evaluations to operate on the latest data with no additional latency. """ - streaming_builder = __streaming_ds_builder(config) + streaming_builder = __streaming_ds_builder() builder = ConfigBuilder() builder.synchronizers(streaming_builder) @@ -134,14 +134,14 @@ def streaming(config: LDConfig) -> ConfigBuilder: return builder -def polling(config: LDConfig) -> ConfigBuilder: +def polling() -> ConfigBuilder: """ Polling configures the SDK to regularly poll an endpoint for flag/segment data in the background. This is less efficient than streaming, but may be necessary in some network environments. """ - polling_builder: Builder[Synchronizer] = __polling_ds_builder(config) + polling_builder: Builder[Synchronizer] = __polling_ds_builder() builder = ConfigBuilder() builder.synchronizers(polling_builder) @@ -160,17 +160,16 @@ def custom() -> ConfigBuilder: return ConfigBuilder() -# TODO(fdv2): Need to update these so they don't rely on the LDConfig -def daemon(config: LDConfig, store: FeatureStore) -> ConfigBuilder: +def daemon(store: FeatureStore) -> ConfigBuilder: """ Daemon configures the SDK to read from a persistent store integration that is populated by Relay Proxy or other SDKs. The SDK will not connect to LaunchDarkly. In this mode, the SDK never writes to the data store. """ - return default(config).data_store(store, DataStoreMode.READ_ONLY) + return default().data_store(store, DataStoreMode.READ_ONLY) -def persistent_store(config: LDConfig, store: FeatureStore) -> ConfigBuilder: +def persistent_store(store: FeatureStore) -> ConfigBuilder: """ PersistentStore is similar to Default, with the addition of a persistent store integration. Before data has arrived from LaunchDarkly, the SDK is @@ -178,7 +177,7 @@ def persistent_store(config: LDConfig, store: FeatureStore) -> ConfigBuilder: data is available, the SDK will no longer read from the persistent store, although it will keep it up-to-date. """ - return default(config).data_store(store, DataStoreMode.READ_WRITE) + return default().data_store(store, DataStoreMode.READ_WRITE) # TODO(fdv2): Implement these methods diff --git a/ldclient/impl/datasystem/fdv2.py b/ldclient/impl/datasystem/fdv2.py index 3106074f..e41386e3 100644 --- a/ldclient/impl/datasystem/fdv2.py +++ b/ldclient/impl/datasystem/fdv2.py @@ -3,7 +3,7 @@ from threading import Event, Thread from typing import Any, Callable, Dict, List, Mapping, Optional -from ldclient.config import Builder, DataSystemConfig +from ldclient.config import Builder, Config, DataSystemConfig from ldclient.feature_store import _FeatureStoreDataSetSorter from ldclient.impl.datasourcev2.status import ( DataSourceStatusProviderImpl, @@ -153,8 +153,8 @@ class FDv2: def __init__( self, - config: DataSystemConfig, - disabled: bool = False, + config: Config, + data_system_config: DataSystemConfig, ): """ Initialize a new FDv2 data system. @@ -165,10 +165,11 @@ def __init__( :param disabled: Whether the data system is disabled (offline mode) """ self._config = config - self._primary_synchronizer_builder: Optional[Builder[Synchronizer]] = config.primary_synchronizer - self._secondary_synchronizer_builder = config.secondary_synchronizer - self._fdv1_fallback_synchronizer_builder = config.fdv1_fallback_synchronizer - self._disabled = disabled + self._data_system_config = data_system_config + self._primary_synchronizer_builder: Optional[Builder[Synchronizer]] = data_system_config.primary_synchronizer + self._secondary_synchronizer_builder = data_system_config.secondary_synchronizer + self._fdv1_fallback_synchronizer_builder = data_system_config.fdv1_fallback_synchronizer + self._disabled = self._config.offline # Diagnostic accumulator provided by client for streaming metrics # TODO(fdv2): Either we need to use this, or we need to provide it to @@ -188,10 +189,10 @@ def __init__( self._data_store_status_provider = DataStoreStatusProviderImpl(None, Listeners()) # Configure persistent store if provided - if self._config.data_store is not None: - self._data_store_status_provider = DataStoreStatusProviderImpl(self._config.data_store, Listeners()) - writable = self._config.data_store_mode == DataStoreMode.READ_WRITE - wrapper = FeatureStoreClientWrapper(self._config.data_store, self._data_store_status_provider) + if self._data_system_config.data_store is not None: + self._data_store_status_provider = DataStoreStatusProviderImpl(self._data_system_config.data_store, Listeners()) + writable = self._data_system_config.data_store_mode == DataStoreMode.READ_WRITE + wrapper = FeatureStoreClientWrapper(self._data_system_config.data_store, self._data_store_status_provider) self._store.with_persistence( wrapper, writable, self._data_store_status_provider ) @@ -208,8 +209,8 @@ def __init__( # Track configuration self._configured_with_data_sources = ( - (config.initializers is not None and len(config.initializers) > 0) - or config.primary_synchronizer is not None + (data_system_config.initializers is not None and len(data_system_config.initializers) > 0) + or data_system_config.primary_synchronizer is not None ) def start(self, set_on_ready: Event): @@ -268,32 +269,32 @@ def _run_main_loop(self, set_on_ready: Event): self._run_synchronizers(set_on_ready) except Exception as e: - log.error(f"Error in FDv2 main loop: {e}") + log.error("Error in FDv2 main loop: %s", e) # Ensure ready event is set even on error if not set_on_ready.is_set(): set_on_ready.set() def _run_initializers(self, set_on_ready: Event): """Run initializers to get initial data.""" - if self._config.initializers is None: + if self._data_system_config.initializers is None: return - for initializer_builder in self._config.initializers: + for initializer_builder in self._data_system_config.initializers: if self._stop_event.is_set(): return try: - initializer = initializer_builder() - log.info(f"Attempting to initialize via {initializer.name}") + initializer = initializer_builder(self._config) + log.info("Attempting to initialize via %s", initializer.name) basis_result = initializer.fetch() if isinstance(basis_result, _Fail): - log.warning(f"Initializer {initializer.name} failed: {basis_result.error}") + log.warning("Initializer %s failed: %s", initializer.name, basis_result.error) continue basis = basis_result.value - log.info(f"Initialized via {initializer.name}") + log.info("Initialized via %s", initializer.name) # Apply the basis to the store self._store.apply(basis.change_set, basis.persist) @@ -302,12 +303,12 @@ def _run_initializers(self, set_on_ready: Event): if not set_on_ready.is_set(): set_on_ready.set() except Exception as e: - log.error(f"Initializer failed with exception: {e}") + log.error("Initializer failed with exception: %s", e) def _run_synchronizers(self, set_on_ready: Event): """Run synchronizers to keep data up-to-date.""" # If no primary synchronizer configured, just set ready and return - if self._config.primary_synchronizer is None: + if self._data_system_config.primary_synchronizer is None: if not set_on_ready.is_set(): set_on_ready.set() return @@ -318,8 +319,8 @@ def synchronizer_loop(self: 'FDv2'): while not self._stop_event.is_set() and self._primary_synchronizer_builder is not None: # Try primary synchronizer try: - primary_sync = self._primary_synchronizer_builder() - log.info(f"Primary synchronizer {primary_sync.name} is starting") + primary_sync = self._primary_synchronizer_builder(self._config) + log.info("Primary synchronizer %s is starting", primary_sync.name) remove_sync, fallback_v1 = self._consume_synchronizer_results( primary_sync, set_on_ready, self._fallback_condition @@ -345,8 +346,8 @@ def synchronizer_loop(self: 'FDv2'): if self._secondary_synchronizer_builder is None: continue - secondary_sync = self._secondary_synchronizer_builder() - log.info(f"Secondary synchronizer {secondary_sync.name} is starting") + secondary_sync = self._secondary_synchronizer_builder(self._config) + log.info("Secondary synchronizer %s is starting", secondary_sync.name) remove_sync, fallback_v1 = self._consume_synchronizer_results( secondary_sync, set_on_ready, self._recovery_condition @@ -368,11 +369,11 @@ def synchronizer_loop(self: 'FDv2'): log.info("Recovery condition met, returning to primary synchronizer") except Exception as e: - log.error(f"Failed to build primary synchronizer: {e}") + log.error("Failed to build primary synchronizer: %s", e) break except Exception as e: - log.error(f"Error in synchronizer loop: {e}") + log.error("Error in synchronizer loop: %s", e) finally: # Ensure we always set the ready event when exiting if not set_on_ready.is_set(): @@ -400,7 +401,7 @@ def _consume_synchronizer_results( """ try: for update in synchronizer.sync(): - log.info(f"Synchronizer {synchronizer.name} update: {update.state}") + log.info("Synchronizer %s update: %s", synchronizer.name, update.state) if self._stop_event.is_set(): return False, False @@ -425,7 +426,7 @@ def _consume_synchronizer_results( return False, False except Exception as e: - log.error(f"Error consuming synchronizer results: {e}") + log.error("Error consuming synchronizer results: %s", e) return True, False return True, False diff --git a/ldclient/integrations/test_datav2.py b/ldclient/integrations/test_datav2.py index 84ccf30d..744264f2 100644 --- a/ldclient/integrations/test_datav2.py +++ b/ldclient/integrations/test_datav2.py @@ -3,6 +3,7 @@ import copy from typing import Any, Dict, List, Optional, Set, Union +from ldclient.config import Config from ldclient.context import Context from ldclient.impl.integrations.test_datav2.test_data_sourcev2 import ( _TestDataSourceV2 @@ -693,7 +694,7 @@ def _add_instance(self, instance): finally: self._lock.unlock() - def build_initializer(self) -> _TestDataSourceV2: + def build_initializer(self, _: Config) -> _TestDataSourceV2: """ Creates an initializer that can be used with the FDv2 data system. @@ -701,7 +702,7 @@ def build_initializer(self) -> _TestDataSourceV2: """ return _TestDataSourceV2(self) - def build_synchronizer(self) -> _TestDataSourceV2: + def build_synchronizer(self, _: Config) -> _TestDataSourceV2: """ Creates a synchronizer that can be used with the FDv2 data system. diff --git a/ldclient/testing/impl/datasystem/test_config.py b/ldclient/testing/impl/datasystem/test_config.py index 5142fb82..a36c748d 100644 --- a/ldclient/testing/impl/datasystem/test_config.py +++ b/ldclient/testing/impl/datasystem/test_config.py @@ -126,9 +126,7 @@ def test_custom_builder(): def test_default_config_builder(): """Test that default() returns a properly configured ConfigBuilder.""" - mock_ld_config = Mock(spec=LDConfig) - - builder = default(mock_ld_config) + builder = default() assert isinstance(builder, ConfigBuilder) # The actual implementation details would be tested in integration tests @@ -137,9 +135,7 @@ def test_default_config_builder(): def test_streaming_config_builder(): """Test that streaming() returns a properly configured ConfigBuilder.""" - mock_ld_config = Mock(spec=LDConfig) - - builder = streaming(mock_ld_config) + builder = streaming() assert isinstance(builder, ConfigBuilder) # The actual implementation details would be tested in integration tests @@ -148,9 +144,7 @@ def test_streaming_config_builder(): def test_polling_config_builder(): """Test that polling() returns a properly configured ConfigBuilder.""" - mock_ld_config = Mock(spec=LDConfig) - - builder = polling(mock_ld_config) + builder = polling() assert isinstance(builder, ConfigBuilder) # The actual implementation details would be tested in integration tests diff --git a/ldclient/testing/impl/datasystem/test_fdv2_datasystem.py b/ldclient/testing/impl/datasystem/test_fdv2_datasystem.py index b0db1426..353dfa0a 100644 --- a/ldclient/testing/impl/datasystem/test_fdv2_datasystem.py +++ b/ldclient/testing/impl/datasystem/test_fdv2_datasystem.py @@ -5,7 +5,7 @@ from mock import Mock -from ldclient.config import DataSystemConfig +from ldclient.config import Config, DataSystemConfig from ldclient.impl.datasystem import DataAvailability, Synchronizer from ldclient.impl.datasystem.fdv2 import FDv2 from ldclient.integrations.test_datav2 import TestDataV2 @@ -18,13 +18,13 @@ def test_two_phase_init(): td_synchronizer = TestDataV2.data_source() td_synchronizer.update(td_synchronizer.flag("feature-flag").on(True)) - config = DataSystemConfig( + data_system_config = DataSystemConfig( initializers=[td_initializer.build_initializer], primary_synchronizer=td_synchronizer.build_synchronizer, ) set_on_ready = Event() - fdv2 = FDv2(config) + fdv2 = FDv2(Config(sdk_key="dummy"), data_system_config) changed = Event() changes: List[FlagChange] = [] @@ -52,13 +52,13 @@ def listener(flag_change: FlagChange): def test_can_stop_fdv2(): td = TestDataV2.data_source() - config = DataSystemConfig( + data_system_config = DataSystemConfig( initializers=None, primary_synchronizer=td.build_synchronizer, ) set_on_ready = Event() - fdv2 = FDv2(config) + fdv2 = FDv2(Config(sdk_key="dummy"), data_system_config) changed = Event() changes: List[FlagChange] = [] @@ -81,13 +81,13 @@ def listener(flag_change: FlagChange): def test_fdv2_data_availability_is_refreshed_with_data(): td = TestDataV2.data_source() - config = DataSystemConfig( + data_system_config = DataSystemConfig( initializers=None, primary_synchronizer=td.build_synchronizer, ) set_on_ready = Event() - fdv2 = FDv2(config) + fdv2 = FDv2(Config(sdk_key="dummy"), data_system_config) fdv2.start(set_on_ready) assert set_on_ready.wait(1), "Data system did not become ready in time" @@ -101,9 +101,9 @@ def test_fdv2_fallsback_to_secondary_synchronizer(): mock.sync.return_value = iter([]) # Empty iterator to simulate no data td = TestDataV2.data_source() td.update(td.flag("feature-flag").on(True)) - config = DataSystemConfig( + data_system_config = DataSystemConfig( initializers=[td.build_initializer], - primary_synchronizer=lambda: mock, # Primary synchronizer is None to force fallback + primary_synchronizer=lambda _: mock, # Primary synchronizer is None to force fallback secondary_synchronizer=td.build_synchronizer, ) @@ -120,7 +120,7 @@ def listener(flag_change: FlagChange): changed.set() set_on_ready = Event() - fdv2 = FDv2(config) + fdv2 = FDv2(Config(sdk_key="dummy"), data_system_config) fdv2.flag_tracker.add_listener(listener) fdv2.start(set_on_ready) assert set_on_ready.wait(1), "Data system did not become ready in time" @@ -137,10 +137,10 @@ def test_fdv2_shutdown_down_if_both_synchronizers_fail(): mock.sync.return_value = iter([]) # Empty iterator to simulate no data td = TestDataV2.data_source() td.update(td.flag("feature-flag").on(True)) - config = DataSystemConfig( + data_system_config = DataSystemConfig( initializers=[td.build_initializer], - primary_synchronizer=lambda: mock, # Primary synchronizer is None to force fallback - secondary_synchronizer=lambda: mock, # Secondary synchronizer also fails + primary_synchronizer=lambda _: mock, # Primary synchronizer is None to force fallback + secondary_synchronizer=lambda _: mock, # Secondary synchronizer also fails ) changed = Event() @@ -150,7 +150,7 @@ def listener(status: DataSourceStatus): changed.set() set_on_ready = Event() - fdv2 = FDv2(config) + fdv2 = FDv2(Config(sdk_key="dummy"), data_system_config) fdv2.data_source_status_provider.add_listener(listener) fdv2.start(set_on_ready) assert set_on_ready.wait(1), "Data system did not become ready in time" diff --git a/ldclient/testing/impl/datasystem/test_fdv2_persistence.py b/ldclient/testing/impl/datasystem/test_fdv2_persistence.py index c64757ab..34cbd4c9 100644 --- a/ldclient/testing/impl/datasystem/test_fdv2_persistence.py +++ b/ldclient/testing/impl/datasystem/test_fdv2_persistence.py @@ -3,16 +3,11 @@ from threading import Event from typing import Any, Callable, Dict, List, Mapping, Optional -from ldclient.config import DataSystemConfig +from ldclient.config import Config, DataSystemConfig from ldclient.impl.datasystem import DataAvailability from ldclient.impl.datasystem.fdv2 import FDv2 from ldclient.integrations.test_datav2 import TestDataV2 -from ldclient.interfaces import ( - DataStoreMode, - DataStoreStatus, - FeatureStore, - FlagChange -) +from ldclient.interfaces import DataStoreMode, FeatureStore, FlagChange from ldclient.versioned_data_kind import FEATURES, SEGMENTS, VersionedDataKind @@ -21,10 +16,16 @@ class StubFeatureStore(FeatureStore): A simple stub implementation of FeatureStore for testing. Records all operations and allows inspection of state. """ - def __init__(self, initial_data: Optional[Mapping[VersionedDataKind, Mapping[str, Dict[Any, Any]]]] = None): + + def __init__( + self, + initial_data: Optional[ + Mapping[VersionedDataKind, Mapping[str, Dict[Any, Any]]] + ] = None, + ): self._data: Dict[VersionedDataKind, Dict[str, dict]] = { FEATURES: {}, - SEGMENTS: {} + SEGMENTS: {}, } self._initialized = False self._available = True @@ -44,16 +45,23 @@ def init(self, all_data: Mapping[VersionedDataKind, Mapping[str, Dict[Any, Any]] self.init_called_count += 1 self._data = { FEATURES: dict(all_data.get(FEATURES, {})), - SEGMENTS: dict(all_data.get(SEGMENTS, {})) + SEGMENTS: dict(all_data.get(SEGMENTS, {})), } self._initialized = True - def get(self, kind: VersionedDataKind, key: str, callback: Callable[[Any], Any] = lambda x: x): + def get( + self, + kind: VersionedDataKind, + key: str, + callback: Callable[[Any], Any] = lambda x: x, + ): self.get_calls.append((kind, key)) item = self._data.get(kind, {}).get(key) return callback(item) if item else None - def all(self, kind: VersionedDataKind, callback: Callable[[Any], Any] = lambda x: x): + def all( + self, kind: VersionedDataKind, callback: Callable[[Any], Any] = lambda x: x + ): self.all_calls.append(kind) items = self._data.get(kind, {}) return {key: callback(value) for key, value in items.items()} @@ -61,14 +69,14 @@ def all(self, kind: VersionedDataKind, callback: Callable[[Any], Any] = lambda x def delete(self, kind: VersionedDataKind, key: str, version: int): self.delete_calls.append((kind, key, version)) existing = self._data.get(kind, {}).get(key) - if existing and existing.get('version', 0) < version: - self._data[kind][key] = {'key': key, 'version': version, 'deleted': True} + if existing and existing.get("version", 0) < version: + self._data[kind][key] = {"key": key, "version": version, "deleted": True} def upsert(self, kind: VersionedDataKind, item: dict): - self.upsert_calls.append((kind, item.get('key'), item.get('version'))) - key = item['key'] + self.upsert_calls.append((kind, item.get("key"), item.get("version"))) + key = item["key"] existing = self._data.get(kind, {}).get(key) - if not existing or existing.get('version', 0) < item.get('version', 0): + if not existing or existing.get("version", 0) < item.get("version", 0): self._data[kind][key] = item @property @@ -95,7 +103,7 @@ def get_data_snapshot(self) -> Mapping[VersionedDataKind, Mapping[str, dict]]: """Test helper to get a snapshot of current data""" return { FEATURES: dict(self._data[FEATURES]), - SEGMENTS: dict(self._data[SEGMENTS]) + SEGMENTS: dict(self._data[SEGMENTS]), } def reset_operation_tracking(self): @@ -112,15 +120,15 @@ def test_persistent_store_read_only_mode(): # Pre-populate persistent store with a flag initial_data = { FEATURES: { - 'existing-flag': { - 'key': 'existing-flag', - 'version': 1, - 'on': True, - 'variations': [True, False], - 'fallthrough': {'variation': 0} + "existing-flag": { + "key": "existing-flag", + "version": 1, + "on": True, + "variations": [True, False], + "fallthrough": {"variation": 0}, } }, - SEGMENTS: {} + SEGMENTS: {}, } persistent_store = StubFeatureStore(initial_data) @@ -129,7 +137,7 @@ def test_persistent_store_read_only_mode(): td_synchronizer = TestDataV2.data_source() td_synchronizer.update(td_synchronizer.flag("new-flag").on(True)) - config = DataSystemConfig( + data_system_config = DataSystemConfig( data_store_mode=DataStoreMode.READ_ONLY, data_store=persistent_store, initializers=None, @@ -137,7 +145,7 @@ def test_persistent_store_read_only_mode(): ) set_on_ready = Event() - fdv2 = FDv2(config) + fdv2 = FDv2(Config(sdk_key="dummy"), data_system_config) fdv2.start(set_on_ready) assert set_on_ready.wait(1), "Data system did not become ready in time" @@ -158,15 +166,15 @@ def test_persistent_store_read_write_mode(): # Pre-populate persistent store with a flag initial_data = { FEATURES: { - 'existing-flag': { - 'key': 'existing-flag', - 'version': 1, - 'on': True, - 'variations': [True, False], - 'fallthrough': {'variation': 0} + "existing-flag": { + "key": "existing-flag", + "version": 1, + "on": True, + "variations": [True, False], + "fallthrough": {"variation": 0}, } }, - SEGMENTS: {} + SEGMENTS: {}, } persistent_store = StubFeatureStore(initial_data) @@ -176,7 +184,7 @@ def test_persistent_store_read_write_mode(): td_synchronizer = TestDataV2.data_source() td_synchronizer.update(td_synchronizer.flag("new-flag").on(True)) - config = DataSystemConfig( + data_system_config = DataSystemConfig( data_store_mode=DataStoreMode.READ_WRITE, data_store=persistent_store, initializers=None, @@ -184,17 +192,19 @@ def test_persistent_store_read_write_mode(): ) set_on_ready = Event() - fdv2 = FDv2(config) + fdv2 = FDv2(Config(sdk_key="dummy"), data_system_config) fdv2.start(set_on_ready) assert set_on_ready.wait(1), "Data system did not become ready in time" # In READ_WRITE mode, the store should be initialized with new data - assert persistent_store.init_called_count >= 1 # At least one init call for the new data + assert ( + persistent_store.init_called_count >= 1 + ) # At least one init call for the new data # Verify the new flag was written to persistent store snapshot = persistent_store.get_data_snapshot() - assert 'new-flag' in snapshot[FEATURES] + assert "new-flag" in snapshot[FEATURES] fdv2.stop() @@ -207,7 +217,7 @@ def test_persistent_store_delta_updates_read_write(): td_synchronizer = TestDataV2.data_source() td_synchronizer.update(td_synchronizer.flag("feature-flag").on(True)) - config = DataSystemConfig( + data_system_config = DataSystemConfig( data_store_mode=DataStoreMode.READ_WRITE, data_store=persistent_store, initializers=None, @@ -215,7 +225,7 @@ def test_persistent_store_delta_updates_read_write(): ) set_on_ready = Event() - fdv2 = FDv2(config) + fdv2 = FDv2(Config(sdk_key="dummy"), data_system_config) # Set up flag change listener to detect the update flag_changed = Event() @@ -223,7 +233,9 @@ def test_persistent_store_delta_updates_read_write(): def listener(flag_change: FlagChange): change_count[0] += 1 - if change_count[0] == 2: # First change is from initial sync, second is our update + if ( + change_count[0] == 2 + ): # First change is from initial sync, second is our update flag_changed.set() fdv2.flag_tracker.add_listener(listener) @@ -241,12 +253,12 @@ def listener(flag_change: FlagChange): # Verify the update was written to persistent store assert len(persistent_store.upsert_calls) > 0 - assert any(call[1] == 'feature-flag' for call in persistent_store.upsert_calls) + assert any(call[1] == "feature-flag" for call in persistent_store.upsert_calls) # Verify the updated flag is in the store snapshot = persistent_store.get_data_snapshot() - assert 'feature-flag' in snapshot[FEATURES] - assert snapshot[FEATURES]['feature-flag']['on'] is False + assert "feature-flag" in snapshot[FEATURES] + assert snapshot[FEATURES]["feature-flag"]["on"] is False fdv2.stop() @@ -259,7 +271,7 @@ def test_persistent_store_delta_updates_read_only(): td_synchronizer = TestDataV2.data_source() td_synchronizer.update(td_synchronizer.flag("feature-flag").on(True)) - config = DataSystemConfig( + data_system_config = DataSystemConfig( data_store_mode=DataStoreMode.READ_ONLY, data_store=persistent_store, initializers=None, @@ -267,7 +279,7 @@ def test_persistent_store_delta_updates_read_only(): ) set_on_ready = Event() - fdv2 = FDv2(config) + fdv2 = FDv2(Config(sdk_key="dummy"), data_system_config) # Set up flag change listener to detect the update flag_changed = Event() @@ -275,7 +287,9 @@ def test_persistent_store_delta_updates_read_only(): def listener(flag_change: FlagChange): change_count[0] += 1 - if change_count[0] == 2: # First change is from initial sync, second is our update + if ( + change_count[0] == 2 + ): # First change is from initial sync, second is our update flag_changed.set() fdv2.flag_tracker.add_listener(listener) @@ -309,7 +323,7 @@ def test_persistent_store_with_initializer_and_synchronizer(): td_synchronizer = TestDataV2.data_source() td_synchronizer.update(td_synchronizer.flag("sync-flag").on(False)) - config = DataSystemConfig( + data_system_config = DataSystemConfig( data_store_mode=DataStoreMode.READ_WRITE, data_store=persistent_store, initializers=[td_initializer.build_initializer], @@ -317,7 +331,7 @@ def test_persistent_store_with_initializer_and_synchronizer(): ) set_on_ready = Event() - fdv2 = FDv2(config) + fdv2 = FDv2(Config(sdk_key="dummy"), data_system_config) # Set up flag change listener to detect when synchronizer data arrives sync_flag_arrived = Event() @@ -338,8 +352,8 @@ def listener(flag_change: FlagChange): # The synchronizer flag should be in the persistent store # (it replaces the init-flag since synchronizer does a full data set) snapshot = persistent_store.get_data_snapshot() - assert 'init-flag' not in snapshot[FEATURES] - assert 'sync-flag' in snapshot[FEATURES] + assert "init-flag" not in snapshot[FEATURES] + assert "sync-flag" in snapshot[FEATURES] fdv2.stop() @@ -361,15 +375,15 @@ def test_persistent_store_delete_operations(): # Pre-populate with a flag initial_data = { FEATURES: { - 'deletable-flag': { - 'key': 'deletable-flag', - 'version': 1, - 'on': True, - 'variations': [True, False], - 'fallthrough': {'variation': 0} + "deletable-flag": { + "key": "deletable-flag", + "version": 1, + "on": True, + "variations": [True, False], + "fallthrough": {"variation": 0}, } }, - SEGMENTS: {} + SEGMENTS: {}, } persistent_store = StubFeatureStore(initial_data) @@ -384,18 +398,18 @@ def test_persistent_store_delete_operations(): Change( action=ChangeType.PUT, kind=ObjectKind.FLAG, - key='deletable-flag', + key="deletable-flag", version=1, object={ - 'key': 'deletable-flag', - 'version': 1, - 'on': True, - 'variations': [True, False], - 'fallthrough': {'variation': 0} - } + "key": "deletable-flag", + "version": 1, + "on": True, + "variations": [True, False], + "fallthrough": {"variation": 0}, + }, ) ], - selector=None + selector=None, ) store.apply(init_changeset, True) @@ -408,19 +422,19 @@ def test_persistent_store_delete_operations(): Change( action=ChangeType.DELETE, kind=ObjectKind.FLAG, - key='deletable-flag', + key="deletable-flag", version=2, - object=None + object=None, ) ], - selector=None + selector=None, ) store.apply(delete_changeset, True) # Verify delete was called on persistent store assert len(persistent_store.delete_calls) > 0 - assert any(call[1] == 'deletable-flag' for call in persistent_store.delete_calls) + assert any(call[1] == "deletable-flag" for call in persistent_store.delete_calls) def test_data_store_status_provider(): @@ -430,7 +444,7 @@ def test_data_store_status_provider(): td_synchronizer = TestDataV2.data_source() td_synchronizer.update(td_synchronizer.flag("feature-flag").on(True)) - config = DataSystemConfig( + data_system_config = DataSystemConfig( data_store_mode=DataStoreMode.READ_WRITE, data_store=persistent_store, initializers=None, @@ -438,7 +452,7 @@ def test_data_store_status_provider(): ) set_on_ready = Event() - fdv2 = FDv2(config) + fdv2 = FDv2(Config(sdk_key="dummy"), data_system_config) # Verify data store status provider exists status_provider = fdv2.data_store_status_provider @@ -462,14 +476,14 @@ def test_data_store_status_monitoring_not_enabled_by_default(): td_synchronizer = TestDataV2.data_source() td_synchronizer.update(td_synchronizer.flag("feature-flag").on(True)) - config = DataSystemConfig( + data_system_config = DataSystemConfig( data_store_mode=DataStoreMode.READ_WRITE, data_store=persistent_store, initializers=None, primary_synchronizer=td_synchronizer.build_synchronizer, ) - fdv2 = FDv2(config) + fdv2 = FDv2(Config(sdk_key="dummy"), data_system_config) # Monitoring should not be enabled because the store doesn't support it status_provider = fdv2.data_store_status_provider @@ -484,14 +498,14 @@ def test_data_store_status_monitoring_enabled_when_supported(): td_synchronizer = TestDataV2.data_source() td_synchronizer.update(td_synchronizer.flag("feature-flag").on(True)) - config = DataSystemConfig( + data_system_config = DataSystemConfig( data_store_mode=DataStoreMode.READ_WRITE, data_store=persistent_store, initializers=None, primary_synchronizer=td_synchronizer.build_synchronizer, ) - fdv2 = FDv2(config) + fdv2 = FDv2(Config(sdk_key="dummy"), data_system_config) # Monitoring should be enabled status_provider = fdv2.data_store_status_provider @@ -503,7 +517,7 @@ def test_no_persistent_store_status_provider_without_store(): td_synchronizer = TestDataV2.data_source() td_synchronizer.update(td_synchronizer.flag("feature-flag").on(True)) - config = DataSystemConfig( + data_system_config = DataSystemConfig( data_store_mode=DataStoreMode.READ_WRITE, data_store=None, initializers=None, @@ -511,7 +525,7 @@ def test_no_persistent_store_status_provider_without_store(): ) set_on_ready = Event() - fdv2 = FDv2(config) + fdv2 = FDv2(Config(sdk_key="dummy"), data_system_config) # Status provider should exist but not be monitoring status_provider = fdv2.data_store_status_provider diff --git a/ldclient/testing/integrations/test_test_data_sourcev2.py b/ldclient/testing/integrations/test_test_data_sourcev2.py index ac52278a..0660ffae 100644 --- a/ldclient/testing/integrations/test_test_data_sourcev2.py +++ b/ldclient/testing/integrations/test_test_data_sourcev2.py @@ -4,6 +4,7 @@ import pytest +from ldclient.config import Config from ldclient.impl.datasystem.protocolv2 import ( ChangeType, IntentCode, @@ -19,7 +20,7 @@ def test_creates_valid_initializer(): """Test that TestDataV2 creates a working initializer""" td = TestDataV2.data_source() - initializer = td.build_initializer() + initializer = td.build_initializer(Config(sdk_key="dummy")) result = initializer.fetch() assert isinstance(result, _Success) @@ -34,7 +35,7 @@ def test_creates_valid_initializer(): def test_creates_valid_synchronizer(): """Test that TestDataV2 creates a working synchronizer""" td = TestDataV2.data_source() - synchronizer = td.build_synchronizer() + synchronizer = td.build_synchronizer(Config(sdk_key="dummy")) updates = [] update_count = 0 @@ -238,7 +239,7 @@ def test_initializer_fetches_flag_data(): td = TestDataV2.data_source() td.update(td.flag('some-flag').variation_for_all(True)) - initializer = td.build_initializer() + initializer = td.build_initializer(Config(sdk_key="dummy")) result = initializer.fetch() assert isinstance(result, _Success) @@ -258,7 +259,7 @@ def test_synchronizer_yields_initial_data(): td = TestDataV2.data_source() td.update(td.flag('initial-flag').variation_for_all(False)) - synchronizer = td.build_synchronizer() + synchronizer = td.build_synchronizer(Config(sdk_key="dummy")) update_iter = iter(synchronizer.sync()) initial_update = next(update_iter) @@ -277,7 +278,7 @@ def test_synchronizer_yields_initial_data(): def test_synchronizer_receives_updates(): """Test that synchronizer receives flag updates""" td = TestDataV2.data_source() - synchronizer = td.build_synchronizer() + synchronizer = td.build_synchronizer(Config(sdk_key="dummy")) updates = [] update_count = 0 @@ -321,8 +322,8 @@ def collect_updates(): def test_multiple_synchronizers_receive_updates(): """Test that multiple synchronizers receive the same updates""" td = TestDataV2.data_source() - sync1 = td.build_synchronizer() - sync2 = td.build_synchronizer() + sync1 = td.build_synchronizer(Config(sdk_key="dummy")) + sync2 = td.build_synchronizer(Config(sdk_key="dummy")) updates1 = [] updates2 = [] @@ -367,7 +368,7 @@ def collect_updates_2(): def test_closed_synchronizer_stops_yielding(): """Test that closed synchronizer stops yielding updates""" td = TestDataV2.data_source() - synchronizer = td.build_synchronizer() + synchronizer = td.build_synchronizer(Config(sdk_key="dummy")) updates = [] @@ -399,7 +400,7 @@ def test_initializer_can_sync(): td = TestDataV2.data_source() td.update(td.flag('test-flag').variation_for_all(True)) - initializer = td.build_initializer() + initializer = td.build_initializer(Config(sdk_key="dummy")) sync_gen = initializer.sync() # Should get initial update with data @@ -438,7 +439,7 @@ def test_version_increment(): def test_error_handling_in_fetch(): """Test error handling in the fetch method""" td = TestDataV2.data_source() - initializer = td.build_initializer() + initializer = td.build_initializer(Config(sdk_key="dummy")) # Close the initializer to trigger error condition initializer.close() From b26c3f3b1f19af013f5bf8003110e5a43abb7cb8 Mon Sep 17 00:00:00 2001 From: "Matthew M. Keeler" Date: Wed, 5 Nov 2025 12:41:43 -0500 Subject: [PATCH 04/13] chore: Add FDv2-compatible contract test support (#362) > [!NOTE] > Introduces FDv2 data system configuration and robust synchronizer lifecycle (start/stop), integrates SSE streaming with payload filter, implements an in-memory feature store, and updates tests and contract client accordingly. > > - **FDv2/Data System**: > - Add `Synchronizer.stop()` to interface and implement stop/lifecycle management in `StreamingDataSource` and `PollingDataSource`. > - Enhance `FDv2` to track/stop the active synchronizer safely with locks; ensure threads shut down cleanly. > - Add `datasystem.config` builders (`polling_ds_builder`, `streaming_ds_builder`), expose `fdv1_fallback_synchronizer` in config. > - **Streaming**: > - Switch to `ld_eventsource.SSEClient`; include payload filter in stream URI. > - Handle stream errors by interrupting/closing SSE; stop on unrecoverable errors; ensure closure on exit. > - **Polling**: > - Add stoppable sync loop with `_stop` flag and `stop()` method. > - **Store**: > - Implement thread-safe `InMemoryFeatureStore` with basic CRUD, init, and diagnostics; integrate with `Store`. > - **Contract tests**: > - Support FDv2 `dataSystem` config (initializers/synchronizers, payloadFilter) in `client_entity.py`. > - **Tests**: > - Update streaming synchronizer tests for new SSE client usage and stop/interrupt behavior. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit e87daa0ca4826d9960e8893a28d67c333fb77523. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- contract-tests/client_entity.py | 71 ++++++++++++- ldclient/impl/datasourcev2/polling.py | 11 ++- ldclient/impl/datasourcev2/streaming.py | 87 +++++----------- ldclient/impl/datasystem/__init__.py | 8 ++ ldclient/impl/datasystem/config.py | 25 ++--- ldclient/impl/datasystem/fdv2.py | 28 ++++++ ldclient/impl/datasystem/store.py | 99 ++++++++++++++++++- .../test_streaming_synchronizer.py | 48 ++++++--- 8 files changed, 285 insertions(+), 92 deletions(-) diff --git a/contract-tests/client_entity.py b/contract-tests/client_entity.py index c0030adb..6b627851 100644 --- a/contract-tests/client_entity.py +++ b/contract-tests/client_entity.py @@ -15,6 +15,12 @@ Stage ) from ldclient.config import BigSegmentsConfig +from ldclient.impl.datasourcev2.polling import PollingDataSourceBuilder +from ldclient.impl.datasystem.config import ( + custom, + polling_ds_builder, + streaming_ds_builder +) class ClientEntity: @@ -29,7 +35,70 @@ def __init__(self, tag, config): 'version': tags.get('applicationVersion', ''), } - if config.get("streaming") is not None: + datasystem_config = config.get('dataSystem') + if datasystem_config is not None: + datasystem = custom() + + init_configs = datasystem_config.get('initializers') + if init_configs is not None: + initializers = [] + for init_config in init_configs: + polling = init_config.get('polling') + if polling is not None: + if polling.get("baseUri") is not None: + opts["base_uri"] = polling["baseUri"] + _set_optional_time_prop(polling, "pollIntervalMs", opts, "poll_interval") + polling = polling_ds_builder() + initializers.append(polling) + + datasystem.initializers(initializers) + sync_config = datasystem_config.get('synchronizers') + if sync_config is not None: + primary = sync_config.get('primary') + secondary = sync_config.get('secondary') + + primary_builder = None + secondary_builder = None + + if primary is not None: + streaming = primary.get('streaming') + if streaming is not None: + primary_builder = streaming_ds_builder() + if streaming.get("baseUri") is not None: + opts["stream_uri"] = streaming["baseUri"] + _set_optional_time_prop(streaming, "initialRetryDelayMs", opts, "initial_reconnect_delay") + primary_builder = streaming_ds_builder() + elif primary.get('polling') is not None: + polling = primary.get('polling') + if polling.get("baseUri") is not None: + opts["base_uri"] = polling["baseUri"] + _set_optional_time_prop(polling, "pollIntervalMs", opts, "poll_interval") + primary_builder = polling_ds_builder() + + if secondary is not None: + streaming = secondary.get('streaming') + if streaming is not None: + secondary_builder = streaming_ds_builder() + if streaming.get("baseUri") is not None: + opts["stream_uri"] = streaming["baseUri"] + _set_optional_time_prop(streaming, "initialRetryDelayMs", opts, "initial_reconnect_delay") + secondary_builder = streaming_ds_builder() + elif secondary.get('polling') is not None: + polling = secondary.get('polling') + if polling.get("baseUri") is not None: + opts["base_uri"] = polling["baseUri"] + _set_optional_time_prop(polling, "pollIntervalMs", opts, "poll_interval") + secondary_builder = polling_ds_builder() + + if primary_builder is not None: + datasystem.synchronizers(primary_builder, secondary_builder) + + if datasystem_config.get("payloadFilter") is not None: + opts["payload_filter_key"] = datasystem_config["payloadFilter"] + + opts["datasystem_config"] = datasystem.build() + + elif config.get("streaming") is not None: streaming = config["streaming"] if streaming.get("baseUri") is not None: opts["stream_uri"] = streaming["baseUri"] diff --git a/ldclient/impl/datasourcev2/polling.py b/ldclient/impl/datasourcev2/polling.py index c77ff8b4..8a350c82 100644 --- a/ldclient/impl/datasourcev2/polling.py +++ b/ldclient/impl/datasourcev2/polling.py @@ -86,6 +86,7 @@ def __init__( self._requester = requester self._poll_interval = poll_interval self._event = Event() + self._stop = Event() self._task = RepeatingTask( "ldclient.datasource.polling", poll_interval, 0, self._poll ) @@ -108,7 +109,8 @@ def sync(self) -> Generator[Update, None, None]: occurs. """ log.info("Starting PollingDataSourceV2 synchronizer") - while True: + self._stop.clear() + while self._stop.is_set() is False: result = self._requester.fetch(None) if isinstance(result, _Fail): if isinstance(result.exception, UnsuccessfulResponseException): @@ -161,6 +163,13 @@ def sync(self) -> Generator[Update, None, None]: if self._event.wait(self._poll_interval): break + def stop(self): + """Stops the synchronizer.""" + log.info("Stopping PollingDataSourceV2 synchronizer") + self._event.set() + self._task.stop() + self._stop.set() + def _poll(self) -> BasisResult: try: # TODO(fdv2): Need to pass the selector through diff --git a/ldclient/impl/datasourcev2/streaming.py b/ldclient/impl/datasourcev2/streaming.py index 808b5238..75e44552 100644 --- a/ldclient/impl/datasourcev2/streaming.py +++ b/ldclient/impl/datasourcev2/streaming.py @@ -9,7 +9,7 @@ from typing import Callable, Generator, Iterable, Optional, Protocol, Tuple from urllib import parse -from ld_eventsource import SSEClient as SSEClientImpl +from ld_eventsource import SSEClient from ld_eventsource.actions import Action, Event, Fault from ld_eventsource.config import ( ConnectStrategy, @@ -54,33 +54,19 @@ STREAMING_ENDPOINT = "/sdk/stream" -class SSEClient(Protocol): # pylint: disable=too-few-public-methods - """ - SSEClient is a protocol that defines the interface for a client that can - connect to a Server-Sent Events (SSE) stream and provide an iterable of - actions received from that stream. - """ - - @property - @abstractmethod - def all(self) -> Iterable[Action]: - """ - Returns an iterable of all actions received from the SSE stream. - """ - raise NotImplementedError - - SseClientBuilder = Callable[[Config], SSEClient] # TODO(sdk-1391): Pass a selector-retrieving function through so it can # re-connect with the last known status. -def create_sse_client(config: Config) -> SSEClientImpl: +def create_sse_client(config: Config) -> SSEClient: """ " - create_sse_client creates an SSEClientImpl instance configured to connect + create_sse_client creates an SSEClient instance configured to connect to the LaunchDarkly streaming endpoint. """ uri = config.stream_base_uri + STREAMING_ENDPOINT + if config.payload_filter_key is not None: + uri += "?%s" % parse.urlencode({"filter": config.payload_filter_key}) # We don't want the stream to use the same read timeout as the rest of the SDK. http_factory = _http_factory(config) @@ -90,7 +76,7 @@ def create_sse_client(config: Config) -> SSEClientImpl: override_read_timeout=STREAM_READ_TIMEOUT, ) - return SSEClientImpl( + return SSEClient( connect=ConnectStrategy.http( url=uri, headers=http_factory.base_headers, @@ -119,15 +105,11 @@ class StreamingDataSource(Synchronizer): from the streaming data source. """ - def __init__( - self, config: Config, sse_client_builder: SseClientBuilder = create_sse_client - ): - self._sse_client_builder = sse_client_builder - self._uri = config.stream_base_uri + STREAMING_ENDPOINT - if config.payload_filter_key is not None: - self._uri += "?%s" % parse.urlencode({"filter": config.payload_filter_key}) + def __init__(self, config: Config): + self._sse_client_builder = create_sse_client self._config = config self._sse: Optional[SSEClient] = None + self._running = False @property def name(self) -> str: @@ -142,13 +124,13 @@ def sync(self) -> Generator[Update, None, None]: Update objects until the connection is closed or an unrecoverable error occurs. """ - log.info("Starting StreamingUpdateProcessor connecting to uri: %s", self._uri) self._sse = self._sse_client_builder(self._config) if self._sse is None: log.error("Failed to create SSE client for streaming updates.") return change_set_builder = ChangeSetBuilder() + self._running = True for action in self._sse.all: if isinstance(action, Fault): @@ -177,8 +159,7 @@ def sync(self) -> Generator[Update, None, None]: log.info( "Error while handling stream event; will restart stream: %s", e ) - # TODO(sdk-1409) - # self._sse.interrupt() + self._sse.interrupt() (update, should_continue) = self._handle_error(e) if update is not None: @@ -189,8 +170,7 @@ def sync(self) -> Generator[Update, None, None]: log.info( "Error while handling stream event; will restart stream: %s", e ) - # TODO(sdk-1409) - # self._sse.interrupt() + self._sse.interrupt() yield Update( state=DataSourceState.INTERRUPTED, @@ -210,27 +190,16 @@ def sync(self) -> Generator[Update, None, None]: # DataSourceState.VALID, None # ) - # if not self._ready.is_set(): - # log.info("StreamingUpdateProcessor initialized ok.") - # self._ready.set() - - # TODO(sdk-1409) - # self._sse.close() - - # TODO(sdk-1409) - # def stop(self): - # self.__stop_with_error_info(None) - # - # def __stop_with_error_info(self, error: Optional[DataSourceErrorInfo]): - # log.info("Stopping StreamingUpdateProcessor") - # self._running = False - # if self._sse: - # self._sse.close() - # - # if self._data_source_update_sink is None: - # return - # - # self._data_source_update_sink.update_status(DataSourceState.OFF, error) + self._sse.close() + + def stop(self): + """ + Stops the streaming synchronizer, closing any open connections. + """ + log.info("Stopping StreamingUpdateProcessor") + self._running = False + if self._sse: + self._sse.close() # pylint: disable=too-many-return-statements def _process_message( @@ -317,8 +286,8 @@ def _handle_error(self, error: Exception) -> Tuple[Optional[Update], bool]: If an update is provided, it should be forward upstream, regardless of whether or not we are going to retry this failure. """ - # if not self._running: - # return (False, None) # don't retry if we've been deliberately stopped + if not self._running: + return (None, False) # don't retry if we've been deliberately stopped update: Optional[Update] = None @@ -362,10 +331,7 @@ def _handle_error(self, error: Exception) -> Tuple[Optional[Update], bool]: if not is_recoverable: log.error(http_error_message_result) - # TODO(sdk-1409) - # self._ready.set() # if client is initializing, make it stop waiting; has no effect if already inited - # self.__stop_with_error_info(error_info) - # self.stop() + self.stop() return (update, False) log.warning(http_error_message_result) @@ -391,8 +357,7 @@ def __enter__(self): return self def __exit__(self, type, value, traceback): - # self.stop() - pass + self.stop() class StreamingDataSourceBuilder: # disable: pylint: disable=too-few-public-methods diff --git a/ldclient/impl/datasystem/__init__.py b/ldclient/impl/datasystem/__init__.py index 15b9e8f0..cc6fbba5 100644 --- a/ldclient/impl/datasystem/__init__.py +++ b/ldclient/impl/datasystem/__init__.py @@ -212,3 +212,11 @@ def sync(self) -> Generator[Update, None, None]: occurs. """ raise NotImplementedError + + @abstractmethod + def stop(self): + """ + stop should halt the synchronization process, causing the sync method + to exit as soon as possible. + """ + raise NotImplementedError diff --git a/ldclient/impl/datasystem/config.py b/ldclient/impl/datasystem/config.py index c02ba952..b179ff9f 100644 --- a/ldclient/impl/datasystem/config.py +++ b/ldclient/impl/datasystem/config.py @@ -28,11 +28,13 @@ class ConfigBuilder: # pylint: disable=too-few-public-methods Builder for the data system configuration. """ - _initializers: Optional[List[Builder[Initializer]]] = None - _primary_synchronizer: Optional[Builder[Synchronizer]] = None - _secondary_synchronizer: Optional[Builder[Synchronizer]] = None - _store_mode: DataStoreMode = DataStoreMode.READ_ONLY - _data_store: Optional[FeatureStore] = None + def __init__(self) -> None: + self._initializers: Optional[List[Builder[Initializer]]] = None + self._primary_synchronizer: Optional[Builder[Synchronizer]] = None + self._secondary_synchronizer: Optional[Builder[Synchronizer]] = None + self._fdv1_fallback_synchronizer: Optional[Builder[Synchronizer]] = None + self._store_mode: DataStoreMode = DataStoreMode.READ_ONLY + self._data_store: Optional[FeatureStore] = None def initializers(self, initializers: Optional[List[Builder[Initializer]]]) -> "ConfigBuilder": """ @@ -72,12 +74,13 @@ def build(self) -> DataSystemConfig: initializers=self._initializers, primary_synchronizer=self._primary_synchronizer, secondary_synchronizer=self._secondary_synchronizer, + fdv1_fallback_synchronizer=self._fdv1_fallback_synchronizer, data_store_mode=self._store_mode, data_store=self._data_store, ) -def __polling_ds_builder() -> Builder[PollingDataSource]: +def polling_ds_builder() -> Builder[PollingDataSource]: def builder(config: LDConfig) -> PollingDataSource: requester = Urllib3PollingRequester(config) polling_ds = PollingDataSourceBuilder(config) @@ -88,7 +91,7 @@ def builder(config: LDConfig) -> PollingDataSource: return builder -def __streaming_ds_builder() -> Builder[StreamingDataSource]: +def streaming_ds_builder() -> Builder[StreamingDataSource]: def builder(config: LDConfig) -> StreamingDataSource: return StreamingDataSourceBuilder(config).build() @@ -109,8 +112,8 @@ def default() -> ConfigBuilder: for updates. """ - polling_builder = __polling_ds_builder() - streaming_builder = __streaming_ds_builder() + polling_builder = polling_ds_builder() + streaming_builder = streaming_ds_builder() builder = ConfigBuilder() builder.initializers([polling_builder]) @@ -126,7 +129,7 @@ def streaming() -> ConfigBuilder: with no additional latency. """ - streaming_builder = __streaming_ds_builder() + streaming_builder = streaming_ds_builder() builder = ConfigBuilder() builder.synchronizers(streaming_builder) @@ -141,7 +144,7 @@ def polling() -> ConfigBuilder: streaming, but may be necessary in some network environments. """ - polling_builder: Builder[Synchronizer] = __polling_ds_builder() + polling_builder: Builder[Synchronizer] = polling_ds_builder() builder = ConfigBuilder() builder.synchronizers(polling_builder) diff --git a/ldclient/impl/datasystem/fdv2.py b/ldclient/impl/datasystem/fdv2.py index e41386e3..01824203 100644 --- a/ldclient/impl/datasystem/fdv2.py +++ b/ldclient/impl/datasystem/fdv2.py @@ -205,6 +205,8 @@ def __init__( # Threading self._stop_event = Event() + self._lock = ReadWriteLock() + self._active_synchronizer: Optional[Synchronizer] = None self._threads: List[Thread] = [] # Track configuration @@ -240,10 +242,20 @@ def stop(self): """Stop the FDv2 data system and all associated threads.""" self._stop_event.set() + self._lock.lock() + if self._active_synchronizer is not None: + try: + self._active_synchronizer.stop() + except Exception as e: + log.error("Error stopping active data source: %s", e) + self._lock.unlock() + # Wait for all threads to complete for thread in self._threads: if thread.is_alive(): thread.join(timeout=5.0) # 5 second timeout + if thread.is_alive(): + log.warning("Thread %s did not terminate in time", thread.name) # Close the store self._store.close() @@ -319,7 +331,11 @@ def synchronizer_loop(self: 'FDv2'): while not self._stop_event.is_set() and self._primary_synchronizer_builder is not None: # Try primary synchronizer try: + self._lock.lock() primary_sync = self._primary_synchronizer_builder(self._config) + self._active_synchronizer = primary_sync + self._lock.unlock() + log.info("Primary synchronizer %s is starting", primary_sync.name) remove_sync, fallback_v1 = self._consume_synchronizer_results( @@ -345,9 +361,14 @@ def synchronizer_loop(self: 'FDv2'): if self._secondary_synchronizer_builder is None: continue + if self._stop_event.is_set(): + break + self._lock.lock() secondary_sync = self._secondary_synchronizer_builder(self._config) log.info("Secondary synchronizer %s is starting", secondary_sync.name) + self._active_synchronizer = secondary_sync + self._lock.unlock() remove_sync, fallback_v1 = self._consume_synchronizer_results( secondary_sync, set_on_ready, self._recovery_condition @@ -378,6 +399,11 @@ def synchronizer_loop(self: 'FDv2'): # Ensure we always set the ready event when exiting if not set_on_ready.is_set(): set_on_ready.set() + self._lock.lock() + if self._active_synchronizer is not None: + self._active_synchronizer.stop() + self._active_synchronizer = None + self._lock.unlock() sync_thread = Thread( target=synchronizer_loop, @@ -428,6 +454,8 @@ def _consume_synchronizer_results( except Exception as e: log.error("Error consuming synchronizer results: %s", e) return True, False + finally: + synchronizer.stop() return True, False diff --git a/ldclient/impl/datasystem/store.py b/ldclient/impl/datasystem/store.py index 94f015e7..dabd5d29 100644 --- a/ldclient/impl/datasystem/store.py +++ b/ldclient/impl/datasystem/store.py @@ -7,9 +7,9 @@ """ import threading -from typing import Dict, List, Mapping, Optional, Set +from collections import defaultdict +from typing import Any, Callable, Dict, List, Mapping, Optional, Set -from ldclient.feature_store import InMemoryFeatureStore from ldclient.impl.datasystem.protocolv2 import ( Change, ChangeSet, @@ -20,15 +20,110 @@ ) from ldclient.impl.dependency_tracker import DependencyTracker, KindAndKey from ldclient.impl.listeners import Listeners +from ldclient.impl.rwlock import ReadWriteLock from ldclient.impl.util import log from ldclient.interfaces import ( DataStoreStatusProvider, + DiagnosticDescription, FeatureStore, FlagChange ) from ldclient.versioned_data_kind import FEATURES, SEGMENTS, VersionedDataKind +class InMemoryFeatureStore(FeatureStore, DiagnosticDescription): + """The default feature store implementation, which holds all data in a thread-safe data structure in memory.""" + + def __init__(self): + """Constructs an instance of InMemoryFeatureStore.""" + self._lock = ReadWriteLock() + self._initialized = False + self._items = defaultdict(dict) + + def is_monitoring_enabled(self) -> bool: + return False + + def is_available(self) -> bool: + return True + + def get(self, kind: VersionedDataKind, key: str, callback: Callable[[Any], Any] = lambda x: x) -> Any: + """ """ + try: + self._lock.rlock() + items_of_kind = self._items[kind] + item = items_of_kind.get(key) + if item is None: + log.debug("Attempted to get missing key %s in '%s', returning None", key, kind.namespace) + return callback(None) + if 'deleted' in item and item['deleted']: + log.debug("Attempted to get deleted key %s in '%s', returning None", key, kind.namespace) + return callback(None) + return callback(item) + finally: + self._lock.runlock() + + def all(self, kind, callback): + """ """ + try: + self._lock.rlock() + items_of_kind = self._items[kind] + return callback(dict((k, i) for k, i in items_of_kind.items() if ('deleted' not in i) or not i['deleted'])) + finally: + self._lock.runlock() + + def init(self, all_data): + """ """ + all_decoded = {} + for kind, items in all_data.items(): + items_decoded = {} + for key, item in items.items(): + items_decoded[key] = kind.decode(item) + all_decoded[kind] = items_decoded + try: + self._lock.lock() + self._items.clear() + self._items.update(all_decoded) + self._initialized = True + for k in all_data: + log.debug("Initialized '%s' store with %d items", k.namespace, len(all_data[k])) + finally: + self._lock.unlock() + + # noinspection PyShadowingNames + def delete(self, kind, key: str, version: int): + """ """ + try: + self._lock.lock() + items_of_kind = self._items[kind] + items_of_kind[key] = {'deleted': True, 'version': version} + finally: + self._lock.unlock() + + def upsert(self, kind, item): + """ """ + decoded_item = kind.decode(item) + key = item['key'] + try: + self._lock.lock() + items_of_kind = self._items[kind] + items_of_kind[key] = decoded_item + log.debug("Updated %s in '%s' to version %d", key, kind.namespace, item['version']) + finally: + self._lock.unlock() + + @property + def initialized(self) -> bool: + """ """ + try: + self._lock.rlock() + return self._initialized + finally: + self._lock.runlock() + + def describe_configuration(self, config): + return 'memory' + + class Store: """ Store is a dual-mode persistent/in-memory store that serves requests for data from the evaluation diff --git a/ldclient/testing/impl/datasourcev2/test_streaming_synchronizer.py b/ldclient/testing/impl/datasourcev2/test_streaming_synchronizer.py index 8aa66bbb..d78aac6c 100644 --- a/ldclient/testing/impl/datasourcev2/test_streaming_synchronizer.py +++ b/ldclient/testing/impl/datasourcev2/test_streaming_synchronizer.py @@ -51,6 +51,12 @@ def __init__( def all(self) -> Iterable[Action]: return self._events + def interrupt(self): + pass + + def close(self): + pass + class HttpExceptionThrowingSseClient: def __init__(self, status_codes: List[int]): # pylint: disable=redefined-outer-name @@ -74,16 +80,16 @@ class UnknownTypeOfEvent(Action): pass unknown_named_event = Event(event="Unknown") - builder = list_sse_client([UnknownTypeOfEvent(), unknown_named_event]) - synchronizer = StreamingDataSource(Config(sdk_key="key"), builder) + synchronizer = StreamingDataSource(Config(sdk_key="key")) + synchronizer._sse_client_builder = list_sse_client([UnknownTypeOfEvent(), unknown_named_event]) assert len(list(synchronizer.sync())) == 0 def test_ignores_faults_without_errors(): errorless_fault = Fault(error=None) - builder = list_sse_client([errorless_fault]) - synchronizer = StreamingDataSource(Config(sdk_key="key"), builder) + synchronizer = StreamingDataSource(Config(sdk_key="key")) + synchronizer._sse_client_builder = list_sse_client([errorless_fault]) assert len(list(synchronizer.sync())) == 0 @@ -160,9 +166,9 @@ def test_handles_no_changes(): event=EventName.SERVER_INTENT, data=json.dumps(server_intent.to_dict()), ) - builder = list_sse_client([intent_event]) - synchronizer = StreamingDataSource(Config(sdk_key="key"), builder) + synchronizer = StreamingDataSource(Config(sdk_key="key")) + synchronizer._sse_client_builder = list_sse_client([intent_event]) updates = list(synchronizer.sync()) assert len(updates) == 1 @@ -181,7 +187,8 @@ def test_handles_empty_changeset(events): # pylint: disable=redefined-outer-nam ] ) - synchronizer = StreamingDataSource(Config(sdk_key="key"), builder) + synchronizer = StreamingDataSource(Config(sdk_key="key")) + synchronizer._sse_client_builder = builder updates = list(synchronizer.sync()) assert len(updates) == 1 @@ -207,7 +214,8 @@ def test_handles_put_objects(events): # pylint: disable=redefined-outer-name ] ) - synchronizer = StreamingDataSource(Config(sdk_key="key"), builder) + synchronizer = StreamingDataSource(Config(sdk_key="key")) + synchronizer._sse_client_builder = builder updates = list(synchronizer.sync()) assert len(updates) == 1 @@ -238,7 +246,8 @@ def test_handles_delete_objects(events): # pylint: disable=redefined-outer-name ] ) - synchronizer = StreamingDataSource(Config(sdk_key="key"), builder) + synchronizer = StreamingDataSource(Config(sdk_key="key")) + synchronizer._sse_client_builder = builder updates = list(synchronizer.sync()) assert len(updates) == 1 @@ -268,7 +277,8 @@ def test_swallows_goodbye(events): # pylint: disable=redefined-outer-name ] ) - synchronizer = StreamingDataSource(Config(sdk_key="key"), builder) + synchronizer = StreamingDataSource(Config(sdk_key="key")) + synchronizer._sse_client_builder = builder updates = list(synchronizer.sync()) assert len(updates) == 1 @@ -294,7 +304,8 @@ def test_swallows_heartbeat(events): # pylint: disable=redefined-outer-name ] ) - synchronizer = StreamingDataSource(Config(sdk_key="key"), builder) + synchronizer = StreamingDataSource(Config(sdk_key="key")) + synchronizer._sse_client_builder = builder updates = list(synchronizer.sync()) assert len(updates) == 1 @@ -322,7 +333,8 @@ def test_error_resets(events): # pylint: disable=redefined-outer-name ] ) - synchronizer = StreamingDataSource(Config(sdk_key="key"), builder) + synchronizer = StreamingDataSource(Config(sdk_key="key")) + synchronizer._sse_client_builder = builder updates = list(synchronizer.sync()) assert len(updates) == 1 @@ -345,7 +357,8 @@ def test_handles_out_of_order(events): # pylint: disable=redefined-outer-name ] ) - synchronizer = StreamingDataSource(Config(sdk_key="key"), builder) + synchronizer = StreamingDataSource(Config(sdk_key="key")) + synchronizer._sse_client_builder = builder updates = list(synchronizer.sync()) assert len(updates) == 1 @@ -375,7 +388,8 @@ def test_invalid_json_decoding(events): # pylint: disable=redefined-outer-name ] ) - synchronizer = StreamingDataSource(Config(sdk_key="key"), builder) + synchronizer = StreamingDataSource(Config(sdk_key="key")) + synchronizer._sse_client_builder = builder updates = list(synchronizer.sync()) assert len(updates) == 2 @@ -407,7 +421,8 @@ def test_stops_on_unrecoverable_status_code( ] ) - synchronizer = StreamingDataSource(Config(sdk_key="key"), builder) + synchronizer = StreamingDataSource(Config(sdk_key="key")) + synchronizer._sse_client_builder = builder updates = list(synchronizer.sync()) assert len(updates) == 1 @@ -436,7 +451,8 @@ def test_continues_on_recoverable_status_code( events[EventName.PAYLOAD_TRANSFERRED], ] ) - synchronizer = StreamingDataSource(Config(sdk_key="key"), builder) + synchronizer = StreamingDataSource(Config(sdk_key="key")) + synchronizer._sse_client_builder = builder updates = list(synchronizer.sync()) assert len(updates) == 3 From f72c4a9c27c56f4aa0d5a4ecc10fa459439ca0bc Mon Sep 17 00:00:00 2001 From: "Matthew M. Keeler" Date: Wed, 5 Nov 2025 13:40:29 -0500 Subject: [PATCH 05/13] chore: Provide selector for use as basis on FDv2 data sources (#363) --- ldclient/impl/datasourcev2/polling.py | 15 +++---- ldclient/impl/datasourcev2/streaming.py | 17 +++++--- ldclient/impl/datasystem/__init__.py | 27 ++++++++++-- ldclient/impl/datasystem/fdv2.py | 4 +- .../test_datav2/test_data_sourcev2.py | 13 +++--- .../datasourcev2/test_polling_initializer.py | 15 ++++--- .../datasourcev2/test_polling_synchronizer.py | 15 ++++--- .../test_streaming_synchronizer.py | 30 +++++++------ .../integrations/test_test_data_sourcev2.py | 43 ++++++++++--------- ldclient/testing/mock_components.py | 9 ++++ 10 files changed, 113 insertions(+), 75 deletions(-) diff --git a/ldclient/impl/datasourcev2/polling.py b/ldclient/impl/datasourcev2/polling.py index 8a350c82..8f867097 100644 --- a/ldclient/impl/datasourcev2/polling.py +++ b/ldclient/impl/datasourcev2/polling.py @@ -14,7 +14,7 @@ import urllib3 from ldclient.config import Config -from ldclient.impl.datasystem import BasisResult, Update +from ldclient.impl.datasystem import BasisResult, SelectorStore, Update from ldclient.impl.datasystem.protocolv2 import ( Basis, ChangeSet, @@ -96,13 +96,13 @@ def name(self) -> str: """Returns the name of the initializer.""" return "PollingDataSourceV2" - def fetch(self) -> BasisResult: + def fetch(self, ss: SelectorStore) -> BasisResult: """ Fetch returns a Basis, or an error if the Basis could not be retrieved. """ - return self._poll() + return self._poll(ss) - def sync(self) -> Generator[Update, None, None]: + def sync(self, ss: SelectorStore) -> Generator[Update, None, None]: """ sync begins the synchronization process for the data source, yielding Update objects until the connection is closed or an unrecoverable error @@ -111,7 +111,7 @@ def sync(self) -> Generator[Update, None, None]: log.info("Starting PollingDataSourceV2 synchronizer") self._stop.clear() while self._stop.is_set() is False: - result = self._requester.fetch(None) + result = self._requester.fetch(ss.selector()) if isinstance(result, _Fail): if isinstance(result.exception, UnsuccessfulResponseException): error_info = DataSourceErrorInfo( @@ -170,10 +170,9 @@ def stop(self): self._task.stop() self._stop.set() - def _poll(self) -> BasisResult: + def _poll(self, ss: SelectorStore) -> BasisResult: try: - # TODO(fdv2): Need to pass the selector through - result = self._requester.fetch(None) + result = self._requester.fetch(ss.selector()) if isinstance(result, _Fail): if isinstance(result.exception, UnsuccessfulResponseException): diff --git a/ldclient/impl/datasourcev2/streaming.py b/ldclient/impl/datasourcev2/streaming.py index 75e44552..0f6590dc 100644 --- a/ldclient/impl/datasourcev2/streaming.py +++ b/ldclient/impl/datasourcev2/streaming.py @@ -19,7 +19,7 @@ from ld_eventsource.errors import HTTPStatusError from ldclient.config import Config -from ldclient.impl.datasystem import Synchronizer, Update +from ldclient.impl.datasystem import SelectorStore, Synchronizer, Update from ldclient.impl.datasystem.protocolv2 import ( ChangeSetBuilder, DeleteObject, @@ -54,12 +54,10 @@ STREAMING_ENDPOINT = "/sdk/stream" -SseClientBuilder = Callable[[Config], SSEClient] +SseClientBuilder = Callable[[Config, SelectorStore], SSEClient] -# TODO(sdk-1391): Pass a selector-retrieving function through so it can -# re-connect with the last known status. -def create_sse_client(config: Config) -> SSEClient: +def create_sse_client(config: Config, ss: SelectorStore) -> SSEClient: """ " create_sse_client creates an SSEClient instance configured to connect to the LaunchDarkly streaming endpoint. @@ -76,12 +74,17 @@ def create_sse_client(config: Config) -> SSEClient: override_read_timeout=STREAM_READ_TIMEOUT, ) + def query_params() -> dict[str, str]: + selector = ss.selector() + return {"basis": selector.state} if selector.is_defined() else {} + return SSEClient( connect=ConnectStrategy.http( url=uri, headers=http_factory.base_headers, pool=stream_http_factory.create_pool_manager(1, uri), urllib3_request_options={"timeout": stream_http_factory.timeout}, + query_params=query_params ), # we'll make error-handling decisions when we see a Fault error_strategy=ErrorStrategy.always_continue(), @@ -118,13 +121,13 @@ def name(self) -> str: """ return "streaming" - def sync(self) -> Generator[Update, None, None]: + def sync(self, ss: SelectorStore) -> Generator[Update, None, None]: """ sync should begin the synchronization process for the data source, yielding Update objects until the connection is closed or an unrecoverable error occurs. """ - self._sse = self._sse_client_builder(self._config) + self._sse = self._sse_client_builder(self._config, ss) if self._sse is None: log.error("Failed to create SSE client for streaming updates.") return diff --git a/ldclient/impl/datasystem/__init__.py b/ldclient/impl/datasystem/__init__.py index cc6fbba5..57131c87 100644 --- a/ldclient/impl/datasystem/__init__.py +++ b/ldclient/impl/datasystem/__init__.py @@ -7,9 +7,9 @@ from dataclasses import dataclass from enum import Enum from threading import Event -from typing import Generator, Optional, Protocol +from typing import Callable, Generator, Optional, Protocol -from ldclient.impl.datasystem.protocolv2 import Basis, ChangeSet +from ldclient.impl.datasystem.protocolv2 import Basis, ChangeSet, Selector from ldclient.impl.util import _Result from ldclient.interfaces import ( DataSourceErrorInfo, @@ -142,6 +142,21 @@ def target_availability(self) -> DataAvailability: raise NotImplementedError +class SelectorStore(Protocol): + """ + SelectorStore represents a component capable of providing Selectors + for data retrieval. + """ + + @abstractmethod + def selector(self) -> Selector: + """ + get_selector should return a Selector object that defines the criteria + for data retrieval. + """ + raise NotImplementedError + + BasisResult = _Result[Basis, str] @@ -165,10 +180,12 @@ def name(self) -> str: raise NotImplementedError @abstractmethod - def fetch(self) -> BasisResult: + def fetch(self, ss: SelectorStore) -> BasisResult: """ fetch should retrieve the initial data set for the data source, returning a Basis object on success, or an error message on failure. + + :param ss: A SelectorStore that provides the Selector to use as a basis for data retrieval. """ raise NotImplementedError @@ -205,11 +222,13 @@ def name(self) -> str: raise NotImplementedError @abstractmethod - def sync(self) -> Generator[Update, None, None]: + def sync(self, ss: SelectorStore) -> Generator[Update, None, None]: """ sync should begin the synchronization process for the data source, yielding Update objects until the connection is closed or an unrecoverable error occurs. + + :param ss: A SelectorStore that provides the Selector to use as a basis for data retrieval. """ raise NotImplementedError diff --git a/ldclient/impl/datasystem/fdv2.py b/ldclient/impl/datasystem/fdv2.py index 01824203..8dd8e5c7 100644 --- a/ldclient/impl/datasystem/fdv2.py +++ b/ldclient/impl/datasystem/fdv2.py @@ -299,7 +299,7 @@ def _run_initializers(self, set_on_ready: Event): initializer = initializer_builder(self._config) log.info("Attempting to initialize via %s", initializer.name) - basis_result = initializer.fetch() + basis_result = initializer.fetch(self._store) if isinstance(basis_result, _Fail): log.warning("Initializer %s failed: %s", initializer.name, basis_result.error) @@ -426,7 +426,7 @@ def _consume_synchronizer_results( :return: Tuple of (should_remove_sync, fallback_to_fdv1) """ try: - for update in synchronizer.sync(): + for update in synchronizer.sync(self._store): log.info("Synchronizer %s update: %s", synchronizer.name, update.state) if self._stop_event.is_set(): return False, False diff --git a/ldclient/impl/integrations/test_datav2/test_data_sourcev2.py b/ldclient/impl/integrations/test_datav2/test_data_sourcev2.py index bf3397c3..6d8edacc 100644 --- a/ldclient/impl/integrations/test_datav2/test_data_sourcev2.py +++ b/ldclient/impl/integrations/test_datav2/test_data_sourcev2.py @@ -2,7 +2,7 @@ from queue import Empty, Queue from typing import Generator -from ldclient.impl.datasystem import BasisResult, Update +from ldclient.impl.datasystem import BasisResult, SelectorStore, Update from ldclient.impl.datasystem.protocolv2 import ( Basis, ChangeSetBuilder, @@ -16,6 +16,7 @@ DataSourceErrorKind, DataSourceState ) +from ldclient.testing.mock_components import MockSelectorStore class _TestDataSourceV2: @@ -47,7 +48,7 @@ def name(self) -> str: """Return the name of this data source.""" return "TestDataV2" - def fetch(self) -> BasisResult: + def fetch(self, ss: SelectorStore) -> BasisResult: """ Implementation of the Initializer.fetch method. @@ -90,7 +91,7 @@ def fetch(self) -> BasisResult: except Exception as e: return _Fail(f"Error fetching test data: {str(e)}") - def sync(self) -> Generator[Update, None, None]: + def sync(self, ss: SelectorStore) -> Generator[Update, None, None]: """ Implementation of the Synchronizer.sync method. @@ -98,7 +99,7 @@ def sync(self) -> Generator[Update, None, None]: """ # First yield initial data - initial_result = self.fetch() + initial_result = self.fetch(ss) if isinstance(initial_result, _Fail): yield Update( state=DataSourceState.OFF, @@ -143,8 +144,8 @@ def sync(self) -> Generator[Update, None, None]: ) break - def close(self): - """Close the data source and clean up resources.""" + def stop(self): + """Stop the data source and clean up resources""" with self._lock: if self._closed: return diff --git a/ldclient/testing/impl/datasourcev2/test_polling_initializer.py b/ldclient/testing/impl/datasourcev2/test_polling_initializer.py index 0a7079d6..5e5e084f 100644 --- a/ldclient/testing/impl/datasourcev2/test_polling_initializer.py +++ b/ldclient/testing/impl/datasourcev2/test_polling_initializer.py @@ -11,6 +11,7 @@ ) from ldclient.impl.datasystem.protocolv2 import ChangeSetBuilder, IntentCode from ldclient.impl.util import UnsuccessfulResponseException, _Fail, _Success +from ldclient.testing.mock_components import MockSelectorStore class MockExceptionThrowingPollingRequester: # pylint: disable=too-few-public-methods @@ -37,7 +38,7 @@ def test_error_is_returned_on_failure(): mock_requester = MockPollingRequester(_Fail(error="failure message")) ds = PollingDataSource(poll_interval=1.0, requester=mock_requester) - result = ds.fetch() + result = ds.fetch(MockSelectorStore(Selector.no_selector())) assert isinstance(result, _Fail) assert result.error == "failure message" @@ -50,7 +51,7 @@ def test_error_is_recoverable(): ) ds = PollingDataSource(poll_interval=1.0, requester=mock_requester) - result = ds.fetch() + result = ds.fetch(MockSelectorStore(Selector.no_selector())) assert isinstance(result, _Fail) assert result.error is not None @@ -64,7 +65,7 @@ def test_error_is_unrecoverable(): ) ds = PollingDataSource(poll_interval=1.0, requester=mock_requester) - result = ds.fetch() + result = ds.fetch(MockSelectorStore(Selector.no_selector())) assert isinstance(result, _Fail) assert result.error is not None @@ -78,7 +79,7 @@ def test_handles_transfer_none(): ) ds = PollingDataSource(poll_interval=1.0, requester=mock_requester) - result = ds.fetch() + result = ds.fetch(MockSelectorStore(Selector.no_selector())) assert isinstance(result, _Success) assert result.value is not None @@ -92,7 +93,7 @@ def test_handles_uncaught_exception(): mock_requester = MockExceptionThrowingPollingRequester() ds = PollingDataSource(poll_interval=1.0, requester=mock_requester) - result = ds.fetch() + result = ds.fetch(MockSelectorStore(Selector.no_selector())) assert isinstance(result, _Fail) assert result.error is not None @@ -111,7 +112,7 @@ def test_handles_transfer_full(): mock_requester = MockPollingRequester(_Success(value=(change_set_result.value, {}))) ds = PollingDataSource(poll_interval=1.0, requester=mock_requester) - result = ds.fetch() + result = ds.fetch(MockSelectorStore(Selector.no_selector())) assert isinstance(result, _Success) assert result.value is not None @@ -129,7 +130,7 @@ def test_handles_transfer_changes(): mock_requester = MockPollingRequester(_Success(value=(change_set_result.value, {}))) ds = PollingDataSource(poll_interval=1.0, requester=mock_requester) - result = ds.fetch() + result = ds.fetch(MockSelectorStore(Selector.no_selector())) assert isinstance(result, _Success) assert result.value is not None diff --git a/ldclient/testing/impl/datasourcev2/test_polling_synchronizer.py b/ldclient/testing/impl/datasourcev2/test_polling_synchronizer.py index 92391368..3410a1e6 100644 --- a/ldclient/testing/impl/datasourcev2/test_polling_synchronizer.py +++ b/ldclient/testing/impl/datasourcev2/test_polling_synchronizer.py @@ -22,6 +22,7 @@ ) from ldclient.impl.util import UnsuccessfulResponseException, _Fail, _Success from ldclient.interfaces import DataSourceErrorKind, DataSourceState +from ldclient.testing.mock_components import MockSelectorStore class ListBasedRequester: @@ -103,7 +104,7 @@ def test_handles_no_changes(): poll_interval=0.01, requester=ListBasedRequester(results=iter([polling_result])) ) - valid = next(synchronizer.sync()) + valid = next(synchronizer.sync(MockSelectorStore(Selector.no_selector()))) assert valid.state == DataSourceState.VALID assert valid.error is None @@ -124,7 +125,7 @@ def test_handles_empty_changeset(): synchronizer = PollingDataSource( poll_interval=0.01, requester=ListBasedRequester(results=iter([polling_result])) ) - valid = next(synchronizer.sync()) + valid = next(synchronizer.sync(MockSelectorStore(Selector.no_selector()))) assert valid.state == DataSourceState.VALID assert valid.error is None @@ -152,7 +153,7 @@ def test_handles_put_objects(): synchronizer = PollingDataSource( poll_interval=0.01, requester=ListBasedRequester(results=iter([polling_result])) ) - valid = next(synchronizer.sync()) + valid = next(synchronizer.sync(MockSelectorStore(Selector.no_selector()))) assert valid.state == DataSourceState.VALID assert valid.error is None @@ -183,7 +184,7 @@ def test_handles_delete_objects(): synchronizer = PollingDataSource( poll_interval=0.01, requester=ListBasedRequester(results=iter([polling_result])) ) - valid = next(synchronizer.sync()) + valid = next(synchronizer.sync(MockSelectorStore(Selector.no_selector()))) assert valid.state == DataSourceState.VALID assert valid.error is None @@ -216,7 +217,7 @@ def test_generic_error_interrupts_and_recovers(): results=iter([_Fail(error="error for test"), polling_result]) ), ) - sync = synchronizer.sync() + sync = synchronizer.sync(MockSelectorStore(Selector.no_selector())) interrupted = next(sync) valid = next(sync) @@ -250,7 +251,7 @@ def test_recoverable_error_continues(): poll_interval=0.01, requester=ListBasedRequester(results=iter([_failure, polling_result])), ) - sync = synchronizer.sync() + sync = synchronizer.sync(MockSelectorStore(Selector.no_selector())) interrupted = next(sync) valid = next(sync) @@ -288,7 +289,7 @@ def test_unrecoverable_error_shuts_down(): poll_interval=0.01, requester=ListBasedRequester(results=iter([_failure, polling_result])), ) - sync = synchronizer.sync() + sync = synchronizer.sync(MockSelectorStore(Selector.no_selector())) off = next(sync) assert off.state == DataSourceState.OFF assert off.error is not None diff --git a/ldclient/testing/impl/datasourcev2/test_streaming_synchronizer.py b/ldclient/testing/impl/datasourcev2/test_streaming_synchronizer.py index d78aac6c..f749bff8 100644 --- a/ldclient/testing/impl/datasourcev2/test_streaming_synchronizer.py +++ b/ldclient/testing/impl/datasourcev2/test_streaming_synchronizer.py @@ -16,6 +16,7 @@ SseClientBuilder, StreamingDataSource ) +from ldclient.impl.datasystem import SelectorStore from ldclient.impl.datasystem.protocolv2 import ( ChangeType, DeleteObject, @@ -30,12 +31,13 @@ ServerIntent ) from ldclient.interfaces import DataSourceErrorKind, DataSourceState +from ldclient.testing.mock_components import MockSelectorStore def list_sse_client( events: Iterable[Action], # pylint: disable=redefined-outer-name ) -> SseClientBuilder: - def builder(_: Config) -> SSEClient: + def builder(config: Config, ss: SelectorStore) -> SSEClient: return ListBasedSseClient(events) return builder @@ -83,7 +85,7 @@ class UnknownTypeOfEvent(Action): synchronizer = StreamingDataSource(Config(sdk_key="key")) synchronizer._sse_client_builder = list_sse_client([UnknownTypeOfEvent(), unknown_named_event]) - assert len(list(synchronizer.sync())) == 0 + assert len(list(synchronizer.sync(MockSelectorStore(Selector.no_selector())))) == 0 def test_ignores_faults_without_errors(): @@ -91,7 +93,7 @@ def test_ignores_faults_without_errors(): synchronizer = StreamingDataSource(Config(sdk_key="key")) synchronizer._sse_client_builder = list_sse_client([errorless_fault]) - assert len(list(synchronizer.sync())) == 0 + assert len(list(synchronizer.sync(MockSelectorStore(Selector.no_selector())))) == 0 @pytest.fixture @@ -169,7 +171,7 @@ def test_handles_no_changes(): synchronizer = StreamingDataSource(Config(sdk_key="key")) synchronizer._sse_client_builder = list_sse_client([intent_event]) - updates = list(synchronizer.sync()) + updates = list(synchronizer.sync(MockSelectorStore(Selector.no_selector()))) assert len(updates) == 1 assert updates[0].state == DataSourceState.VALID @@ -189,7 +191,7 @@ def test_handles_empty_changeset(events): # pylint: disable=redefined-outer-nam synchronizer = StreamingDataSource(Config(sdk_key="key")) synchronizer._sse_client_builder = builder - updates = list(synchronizer.sync()) + updates = list(synchronizer.sync(MockSelectorStore(Selector.no_selector()))) assert len(updates) == 1 assert updates[0].state == DataSourceState.VALID @@ -216,7 +218,7 @@ def test_handles_put_objects(events): # pylint: disable=redefined-outer-name synchronizer = StreamingDataSource(Config(sdk_key="key")) synchronizer._sse_client_builder = builder - updates = list(synchronizer.sync()) + updates = list(synchronizer.sync(MockSelectorStore(Selector.no_selector()))) assert len(updates) == 1 assert updates[0].state == DataSourceState.VALID @@ -248,7 +250,7 @@ def test_handles_delete_objects(events): # pylint: disable=redefined-outer-name synchronizer = StreamingDataSource(Config(sdk_key="key")) synchronizer._sse_client_builder = builder - updates = list(synchronizer.sync()) + updates = list(synchronizer.sync(MockSelectorStore(Selector.no_selector()))) assert len(updates) == 1 assert updates[0].state == DataSourceState.VALID @@ -279,7 +281,7 @@ def test_swallows_goodbye(events): # pylint: disable=redefined-outer-name synchronizer = StreamingDataSource(Config(sdk_key="key")) synchronizer._sse_client_builder = builder - updates = list(synchronizer.sync()) + updates = list(synchronizer.sync(MockSelectorStore(Selector.no_selector()))) assert len(updates) == 1 assert updates[0].state == DataSourceState.VALID @@ -306,7 +308,7 @@ def test_swallows_heartbeat(events): # pylint: disable=redefined-outer-name synchronizer = StreamingDataSource(Config(sdk_key="key")) synchronizer._sse_client_builder = builder - updates = list(synchronizer.sync()) + updates = list(synchronizer.sync(MockSelectorStore(Selector.no_selector()))) assert len(updates) == 1 assert updates[0].state == DataSourceState.VALID @@ -335,7 +337,7 @@ def test_error_resets(events): # pylint: disable=redefined-outer-name synchronizer = StreamingDataSource(Config(sdk_key="key")) synchronizer._sse_client_builder = builder - updates = list(synchronizer.sync()) + updates = list(synchronizer.sync(MockSelectorStore(Selector.no_selector()))) assert len(updates) == 1 assert updates[0].state == DataSourceState.VALID @@ -359,7 +361,7 @@ def test_handles_out_of_order(events): # pylint: disable=redefined-outer-name synchronizer = StreamingDataSource(Config(sdk_key="key")) synchronizer._sse_client_builder = builder - updates = list(synchronizer.sync()) + updates = list(synchronizer.sync(MockSelectorStore(Selector.no_selector()))) assert len(updates) == 1 assert updates[0].state == DataSourceState.INTERRUPTED @@ -390,7 +392,7 @@ def test_invalid_json_decoding(events): # pylint: disable=redefined-outer-name synchronizer = StreamingDataSource(Config(sdk_key="key")) synchronizer._sse_client_builder = builder - updates = list(synchronizer.sync()) + updates = list(synchronizer.sync(MockSelectorStore(Selector.no_selector()))) assert len(updates) == 2 assert updates[0].state == DataSourceState.INTERRUPTED @@ -423,7 +425,7 @@ def test_stops_on_unrecoverable_status_code( synchronizer = StreamingDataSource(Config(sdk_key="key")) synchronizer._sse_client_builder = builder - updates = list(synchronizer.sync()) + updates = list(synchronizer.sync(MockSelectorStore(Selector.no_selector()))) assert len(updates) == 1 assert updates[0].state == DataSourceState.OFF @@ -453,7 +455,7 @@ def test_continues_on_recoverable_status_code( ) synchronizer = StreamingDataSource(Config(sdk_key="key")) synchronizer._sse_client_builder = builder - updates = list(synchronizer.sync()) + updates = list(synchronizer.sync(MockSelectorStore(Selector.no_selector()))) assert len(updates) == 3 assert updates[0].state == DataSourceState.INTERRUPTED diff --git a/ldclient/testing/integrations/test_test_data_sourcev2.py b/ldclient/testing/integrations/test_test_data_sourcev2.py index 0660ffae..e0ff825d 100644 --- a/ldclient/testing/integrations/test_test_data_sourcev2.py +++ b/ldclient/testing/integrations/test_test_data_sourcev2.py @@ -8,11 +8,13 @@ from ldclient.impl.datasystem.protocolv2 import ( ChangeType, IntentCode, - ObjectKind + ObjectKind, + Selector ) from ldclient.impl.util import _Fail, _Success from ldclient.integrations.test_datav2 import FlagBuilderV2, TestDataV2 from ldclient.interfaces import DataSourceState +from ldclient.testing.mock_components import MockSelectorStore # Test Data + Data Source V2 @@ -22,7 +24,7 @@ def test_creates_valid_initializer(): td = TestDataV2.data_source() initializer = td.build_initializer(Config(sdk_key="dummy")) - result = initializer.fetch() + result = initializer.fetch(MockSelectorStore(Selector.no_selector())) assert isinstance(result, _Success) basis = result.value @@ -42,7 +44,7 @@ def test_creates_valid_synchronizer(): def collect_updates(): nonlocal update_count - for update in synchronizer.sync(): + for update in synchronizer.sync(MockSelectorStore(Selector.no_selector())): updates.append(update) update_count += 1 @@ -51,7 +53,7 @@ def collect_updates(): assert update.state == DataSourceState.VALID assert update.change_set is not None assert update.change_set.intent_code == IntentCode.TRANSFER_FULL - synchronizer.close() + synchronizer.stop() break # Start the synchronizer in a thread with timeout to prevent hanging @@ -63,7 +65,7 @@ def collect_updates(): # Ensure thread completed successfully if sync_thread.is_alive(): - synchronizer.close() + synchronizer.stop() sync_thread.join() pytest.fail("Synchronizer test timed out after 5 seconds") @@ -240,7 +242,7 @@ def test_initializer_fetches_flag_data(): td.update(td.flag('some-flag').variation_for_all(True)) initializer = td.build_initializer(Config(sdk_key="dummy")) - result = initializer.fetch() + result = initializer.fetch(MockSelectorStore(Selector.no_selector())) assert isinstance(result, _Success) basis = result.value @@ -261,7 +263,7 @@ def test_synchronizer_yields_initial_data(): synchronizer = td.build_synchronizer(Config(sdk_key="dummy")) - update_iter = iter(synchronizer.sync()) + update_iter = iter(synchronizer.sync(MockSelectorStore(Selector.no_selector()))) initial_update = next(update_iter) assert initial_update.state == DataSourceState.VALID @@ -272,7 +274,7 @@ def test_synchronizer_yields_initial_data(): change = initial_update.change_set.changes[0] assert change.key == 'initial-flag' - synchronizer.close() + synchronizer.stop() def test_synchronizer_receives_updates(): @@ -285,12 +287,12 @@ def test_synchronizer_receives_updates(): def collect_updates(): nonlocal update_count - for update in synchronizer.sync(): + for update in synchronizer.sync(MockSelectorStore(Selector.no_selector())): updates.append(update) update_count += 1 if update_count >= 2: - synchronizer.close() + synchronizer.stop() break # Start the synchronizer in a thread @@ -329,17 +331,17 @@ def test_multiple_synchronizers_receive_updates(): updates2 = [] def collect_updates_1(): - for update in sync1.sync(): + for update in sync1.sync(MockSelectorStore(Selector.no_selector())): updates1.append(update) if len(updates1) >= 2: - sync1.close() + sync1.stop() break def collect_updates_2(): - for update in sync2.sync(): + for update in sync2.sync(MockSelectorStore(Selector.no_selector())): updates2.append(update) if len(updates2) >= 2: - sync2.close() + sync2.stop() break # Start both synchronizers @@ -373,9 +375,9 @@ def test_closed_synchronizer_stops_yielding(): updates = [] # Get initial update then close - for update in synchronizer.sync(): + for update in synchronizer.sync(MockSelectorStore(Selector.no_selector())): updates.append(update) - synchronizer.close() + synchronizer.stop() break assert len(updates) == 1 @@ -385,7 +387,7 @@ def test_closed_synchronizer_stops_yielding(): # Try to get more updates - should get an error state indicating closure additional_updates = [] - for update in synchronizer.sync(): + for update in synchronizer.sync(MockSelectorStore(Selector.no_selector())): additional_updates.append(update) break @@ -401,11 +403,12 @@ def test_initializer_can_sync(): td.update(td.flag('test-flag').variation_for_all(True)) initializer = td.build_initializer(Config(sdk_key="dummy")) - sync_gen = initializer.sync() + sync_gen = initializer.sync(MockSelectorStore(Selector.no_selector())) # Should get initial update with data initial_update = next(sync_gen) assert initial_update.state == DataSourceState.VALID + assert initial_update.change_set is not None assert initial_update.change_set.intent_code == IntentCode.TRANSFER_FULL assert len(initial_update.change_set.changes) == 1 assert initial_update.change_set.changes[0].key == 'test-flag' @@ -442,8 +445,8 @@ def test_error_handling_in_fetch(): initializer = td.build_initializer(Config(sdk_key="dummy")) # Close the initializer to trigger error condition - initializer.close() + initializer.stop() - result = initializer.fetch() + result = initializer.fetch(MockSelectorStore(Selector.no_selector())) assert isinstance(result, _Fail) assert "TestDataV2 source has been closed" in result.error diff --git a/ldclient/testing/mock_components.py b/ldclient/testing/mock_components.py index 44d3f78a..f1b20235 100644 --- a/ldclient/testing/mock_components.py +++ b/ldclient/testing/mock_components.py @@ -1,6 +1,7 @@ import time from typing import Callable +from ldclient.impl.datasystem.protocolv2 import Selector from ldclient.interfaces import BigSegmentStore, BigSegmentStoreMetadata @@ -42,3 +43,11 @@ def membership_queries(self) -> list: def __fail(self): raise Exception("deliberate error") + + +class MockSelectorStore(): + def __init__(self, selector: Selector): + self._selector = selector + + def selector(self) -> Selector: + return self._selector From 63a3a0918ab49c488e8b4b9818a91acfdfb65054 Mon Sep 17 00:00:00 2001 From: "Matthew M. Keeler" Date: Fri, 7 Nov 2025 14:45:46 -0500 Subject: [PATCH 06/13] chore: Ensure memory store operations are atomic (#367) --- ldclient/client.py | 5 +- ldclient/impl/datasystem/__init__.py | 11 +- ldclient/impl/datasystem/fdv1.py | 3 +- ldclient/impl/datasystem/fdv2.py | 5 +- ldclient/impl/datasystem/protocolv2.py | 4 +- ldclient/impl/datasystem/store.py | 263 ++++++++++-------- ldclient/impl/dependency_tracker.py | 2 +- ldclient/interfaces.py | 19 +- .../impl/datasystem/test_fdv2_persistence.py | 6 +- pyproject.toml | 2 +- 10 files changed, 187 insertions(+), 133 deletions(-) diff --git a/ldclient/client.py b/ldclient/client.py index 71158291..3cd3b9be 100644 --- a/ldclient/client.py +++ b/ldclient/client.py @@ -53,7 +53,8 @@ DataStoreStatusProvider, DataStoreUpdateSink, FeatureStore, - FlagTracker + FlagTracker, + ReadOnlyStore ) from ldclient.migrations import OpTracker, Stage from ldclient.plugin import ( @@ -272,7 +273,7 @@ def __start_up(self, start_wait: float): self._data_system.data_source_status_provider ) self.__flag_tracker = self._data_system.flag_tracker - self._store: FeatureStore = self._data_system.store # type: ignore + self._store: ReadOnlyStore = self._data_system.store big_segment_store_manager = BigSegmentStoreManager(self._config.big_segments) self.__big_segment_store_manager = big_segment_store_manager diff --git a/ldclient/impl/datasystem/__init__.py b/ldclient/impl/datasystem/__init__.py index 57131c87..ec1fb9e0 100644 --- a/ldclient/impl/datasystem/__init__.py +++ b/ldclient/impl/datasystem/__init__.py @@ -16,7 +16,8 @@ DataSourceState, DataSourceStatusProvider, DataStoreStatusProvider, - FlagTracker + FlagTracker, + ReadOnlyStore ) @@ -141,6 +142,14 @@ def target_availability(self) -> DataAvailability: """ raise NotImplementedError + @property + @abstractmethod + def store(self) -> ReadOnlyStore: + """ + Returns the data store used by the data system. + """ + raise NotImplementedError + class SelectorStore(Protocol): """ diff --git a/ldclient/impl/datasystem/fdv1.py b/ldclient/impl/datasystem/fdv1.py index e45498e2..3e57ad34 100644 --- a/ldclient/impl/datasystem/fdv1.py +++ b/ldclient/impl/datasystem/fdv1.py @@ -24,6 +24,7 @@ DataStoreStatusProvider, FeatureStore, FlagTracker, + ReadOnlyStore, UpdateProcessor ) @@ -110,7 +111,7 @@ def stop(self): self._update_processor.stop() @property - def store(self) -> FeatureStore: + def store(self) -> ReadOnlyStore: return self._store_wrapper def set_flag_value_eval_fn(self, eval_fn): diff --git a/ldclient/impl/datasystem/fdv2.py b/ldclient/impl/datasystem/fdv2.py index 8dd8e5c7..8123237b 100644 --- a/ldclient/impl/datasystem/fdv2.py +++ b/ldclient/impl/datasystem/fdv2.py @@ -24,7 +24,8 @@ DataStoreStatus, DataStoreStatusProvider, FeatureStore, - FlagTracker + FlagTracker, + ReadOnlyStore ) from ldclient.versioned_data_kind import VersionedDataKind @@ -500,7 +501,7 @@ def _recovery_condition(self, status: DataSourceStatus) -> bool: return interrupted_at_runtime or healthy_for_too_long or cannot_initialize @property - def store(self) -> FeatureStore: + def store(self) -> ReadOnlyStore: """Get the underlying store for flag evaluation.""" return self._store.get_active_store() diff --git a/ldclient/impl/datasystem/protocolv2.py b/ldclient/impl/datasystem/protocolv2.py index 50cc0862..7feb8a81 100644 --- a/ldclient/impl/datasystem/protocolv2.py +++ b/ldclient/impl/datasystem/protocolv2.py @@ -458,9 +458,7 @@ class Change: kind: ObjectKind key: str version: int - object: Any = ( - None # TODO(fdv2): At some point, we should define a better type for this. - ) + object: Optional[dict] = None @dataclass(frozen=True) diff --git a/ldclient/impl/datasystem/store.py b/ldclient/impl/datasystem/store.py index dabd5d29..15bc432b 100644 --- a/ldclient/impl/datasystem/store.py +++ b/ldclient/impl/datasystem/store.py @@ -8,7 +8,7 @@ import threading from collections import defaultdict -from typing import Any, Callable, Dict, List, Mapping, Optional, Set +from typing import Any, Callable, Dict, List, Optional, Set from ldclient.impl.datasystem.protocolv2 import ( Change, @@ -24,15 +24,20 @@ from ldclient.impl.util import log from ldclient.interfaces import ( DataStoreStatusProvider, - DiagnosticDescription, FeatureStore, - FlagChange + FlagChange, + ReadOnlyStore ) from ldclient.versioned_data_kind import FEATURES, SEGMENTS, VersionedDataKind +Collections = Dict[VersionedDataKind, Dict[str, dict]] -class InMemoryFeatureStore(FeatureStore, DiagnosticDescription): - """The default feature store implementation, which holds all data in a thread-safe data structure in memory.""" + +class InMemoryFeatureStore(ReadOnlyStore): + """ + The default feature store implementation, which holds all data in a + thread-safe data structure in memory. + """ def __init__(self): """Constructs an instance of InMemoryFeatureStore.""" @@ -40,98 +45,131 @@ def __init__(self): self._initialized = False self._items = defaultdict(dict) - def is_monitoring_enabled(self) -> bool: - return False - - def is_available(self) -> bool: - return True - - def get(self, kind: VersionedDataKind, key: str, callback: Callable[[Any], Any] = lambda x: x) -> Any: - """ """ + def get( + self, + kind: VersionedDataKind, + key: str, + callback: Callable[[Any], Any] = lambda x: x, + ) -> Any: try: self._lock.rlock() items_of_kind = self._items[kind] item = items_of_kind.get(key) if item is None: - log.debug("Attempted to get missing key %s in '%s', returning None", key, kind.namespace) + log.debug( + "Attempted to get missing key %s in '%s', returning None", + key, + kind.namespace, + ) return callback(None) - if 'deleted' in item and item['deleted']: - log.debug("Attempted to get deleted key %s in '%s', returning None", key, kind.namespace) + if "deleted" in item and item["deleted"]: + log.debug( + "Attempted to get deleted key %s in '%s', returning None", + key, + kind.namespace, + ) return callback(None) return callback(item) finally: self._lock.runlock() - def all(self, kind, callback): - """ """ + def all(self, kind: VersionedDataKind, callback: Callable[[Any], Any] = lambda x: x) -> Any: try: self._lock.rlock() items_of_kind = self._items[kind] - return callback(dict((k, i) for k, i in items_of_kind.items() if ('deleted' not in i) or not i['deleted'])) + return callback( + dict( + (k, i) + for k, i in items_of_kind.items() + if ("deleted" not in i) or not i["deleted"] + ) + ) finally: self._lock.runlock() - def init(self, all_data): - """ """ - all_decoded = {} - for kind, items in all_data.items(): - items_decoded = {} - for key, item in items.items(): - items_decoded[key] = kind.decode(item) - all_decoded[kind] = items_decoded + def set_basis(self, collections: Collections) -> bool: + """ + Initializes the store with a full set of data, replacing any existing data. + """ + all_decoded = self.__decode_collection(collections) + if all_decoded is None: + return False + try: self._lock.lock() self._items.clear() self._items.update(all_decoded) self._initialized = True - for k in all_data: - log.debug("Initialized '%s' store with %d items", k.namespace, len(all_data[k])) + except Exception as e: + log.error("Failed applying set_basis", exc_info=e) + return False finally: self._lock.unlock() - # noinspection PyShadowingNames - def delete(self, kind, key: str, version: int): - """ """ + return True + + def apply_delta(self, collections: Collections) -> bool: + """ + Applies a delta update to the store. + """ + all_decoded = self.__decode_collection(collections) + if all_decoded is None: + return False + try: self._lock.lock() - items_of_kind = self._items[kind] - items_of_kind[key] = {'deleted': True, 'version': version} + for kind, kind_data in all_decoded.items(): + items_of_kind = self._items[kind] + kind_data = all_decoded[kind] + for key, item in kind_data.items(): + items_of_kind[key] = item + log.debug( + "Updated %s in '%s' to version %d", key, kind.namespace, item["version"] + ) + except Exception as e: + log.error("Failed applying apply_delta", exc_info=e) + return False finally: self._lock.unlock() - def upsert(self, kind, item): - """ """ - decoded_item = kind.decode(item) - key = item['key'] + return True + + def __decode_collection(self, collections: Collections) -> Optional[Dict[VersionedDataKind, Dict[str, Any]]]: try: - self._lock.lock() - items_of_kind = self._items[kind] - items_of_kind[key] = decoded_item - log.debug("Updated %s in '%s' to version %d", key, kind.namespace, item['version']) - finally: - self._lock.unlock() + all_decoded = {} + for kind in collections: + collection = collections[kind] + items_decoded = {} + for key in collection: + items_decoded[key] = kind.decode(collection[key]) + all_decoded[kind] = items_decoded + + return all_decoded + except Exception as e: + log.error("Failed decoding collection.", exc_info=e) + return None @property def initialized(self) -> bool: - """ """ + """ + Indicates whether the store has been initialized with data. + """ try: self._lock.rlock() return self._initialized finally: self._lock.runlock() - def describe_configuration(self, config): - return 'memory' - class Store: """ - Store is a dual-mode persistent/in-memory store that serves requests for data from the evaluation - algorithm. + Store is a dual-mode persistent/in-memory store that serves requests for + data from the evaluation algorithm. - At any given moment one of two stores is active: in-memory, or persistent. Once the in-memory - store has data (either from initializers or a synchronizer), the persistent store is no longer - read from. From that point forward, calls to get data will serve from the memory store. + At any given moment one of two stores is active: in-memory, or persistent. + Once the in-memory store has data (either from initializers or a + synchronizer), the persistent store is no longer read from. From that point + forward, calls to get data will serve from the memory store. """ def __init__( @@ -164,7 +202,7 @@ def __init__( self._persist = False # Points to the active store. Swapped upon initialization. - self._active_store: FeatureStore = self._memory_store + self._active_store: ReadOnlyStore = self._memory_store # Identifies the current data self._selector = Selector.no_selector() @@ -211,7 +249,7 @@ def close(self) -> Optional[Exception]: try: # Most FeatureStore implementations don't have close methods # but we'll try to call it if it exists - if hasattr(self._persistent_store, 'close'): + if hasattr(self._persistent_store, "close"): self._persistent_store.close() except Exception as e: return e @@ -225,12 +263,14 @@ def apply(self, change_set: ChangeSet, persist: bool) -> None: change_set: The changeset to apply persist: Whether the changes should be persisted to the persistent store """ + collections = self._changes_to_store_data(change_set.changes) + with self._lock: try: if change_set.intent_code == IntentCode.TRANSFER_FULL: - self._set_basis(change_set, persist) + self._set_basis(collections, change_set.selector, persist) elif change_set.intent_code == IntentCode.TRANSFER_CHANGES: - self._apply_delta(change_set, persist) + self._apply_delta(collections, change_set.selector, persist) elif change_set.intent_code == IntentCode.TRANSFER_NONE: # No-op, no changes to apply return @@ -240,9 +280,11 @@ def apply(self, change_set: ChangeSet, persist: bool) -> None: except Exception as e: # Log error but don't re-raise - matches Go behavior - log.error(f"Store: couldn't apply changeset: {e}") + log.error("Store: couldn't apply changeset: %s", str(e)) - def _set_basis(self, change_set: ChangeSet, persist: bool) -> None: + def _set_basis( + self, collections: Collections, selector: Optional[Selector], persist: bool + ) -> None: """ Set the basis of the store. Any existing data is discarded. @@ -251,39 +293,40 @@ def _set_basis(self, change_set: ChangeSet, persist: bool) -> None: persist: Whether to persist the data to the persistent store """ # Take snapshot for change detection if we have flag listeners - old_data: Optional[Mapping[VersionedDataKind, Mapping[str, dict]]] = None + old_data: Optional[Collections] = None if self._flag_change_listeners.has_listeners(): old_data = {} for kind in [FEATURES, SEGMENTS]: old_data[kind] = self._memory_store.all(kind, lambda x: x) - # Convert changes to the format expected by FeatureStore.init() - all_data = self._changes_to_store_data(change_set.changes) - - # Initialize memory store with new data - self._memory_store.init(all_data) + ok = self._memory_store.set_basis(collections) + if ok is False: + return # Update dependency tracker - self._reset_dependency_tracker(all_data) + self._reset_dependency_tracker(collections) # Send change events if we had listeners if old_data is not None: - affected_items = self._compute_changed_items_for_full_data_set(old_data, all_data) + affected_items = self._compute_changed_items_for_full_data_set( + old_data, collections + ) self._send_change_events(affected_items) # Update state self._persist = persist - if change_set.selector is not None: - self._selector = change_set.selector + self._selector = selector if selector is not None else Selector.no_selector() # Switch to memory store as active self._active_store = self._memory_store # Persist to persistent store if configured and writable if self._should_persist(): - self._persistent_store.init(all_data) # type: ignore + self._persistent_store.init(collections) # type: ignore - def _apply_delta(self, change_set: ChangeSet, persist: bool) -> None: + def _apply_delta( + self, collections: Collections, selector: Optional[Selector], persist: bool + ) -> None: """ Apply a delta update to the store. @@ -291,53 +334,39 @@ def _apply_delta(self, change_set: ChangeSet, persist: bool) -> None: change_set: The changeset containing the delta changes persist: Whether to persist the changes to the persistent store """ + ok = self._memory_store.apply_delta(collections) + if ok is False: + return + has_listeners = self._flag_change_listeners.has_listeners() affected_items: Set[KindAndKey] = set() - # Apply each change - for change in change_set.changes: - if change.action == ChangeType.PUT: - # Convert to VersionedDataKind - kind = FEATURES if change.kind == ObjectKind.FLAG else SEGMENTS - item = change.object - if item is not None: - self._memory_store.upsert(kind, item) - - # Update dependency tracking - self._dependency_tracker.update_dependencies_from(kind, change.key, item) - if has_listeners: - self._dependency_tracker.add_affected_items( - affected_items, KindAndKey(kind=kind, key=change.key) - ) - - # Persist to persistent store if configured - if self._should_persist(): - self._persistent_store.upsert(kind, item) # type: ignore - - elif change.action == ChangeType.DELETE: - # Convert to VersionedDataKind - kind = FEATURES if change.kind == ObjectKind.FLAG else SEGMENTS - self._memory_store.delete(kind, change.key, change.version) - - # Update dependency tracking - self._dependency_tracker.update_dependencies_from(kind, change.key, None) + for kind in collections: + collection = collections[kind] + for key in collection: + item = collection[key] + self._dependency_tracker.update_dependencies_from( + kind, key, item + ) if has_listeners: self._dependency_tracker.add_affected_items( - affected_items, KindAndKey(kind=kind, key=change.key) + affected_items, KindAndKey(kind=kind, key=key) ) - # Persist to persistent store if configured - if self._should_persist(): - self._persistent_store.delete(kind, change.key, change.version) # type: ignore - # Send change events if affected_items: self._send_change_events(affected_items) # Update state self._persist = persist - if change_set.selector is not None: - self._selector = change_set.selector + self._selector = selector if selector is not None else Selector.no_selector() + + if self._should_persist(): + for kind in collections: + kind_data: Dict[str, dict] = collections[kind] + for i in kind_data: + item = kind_data[i] + self._persistent_store.upsert(kind, item) # type: ignore def _should_persist(self) -> bool: """Returns whether data should be persisted to the persistent store.""" @@ -347,33 +376,31 @@ def _should_persist(self) -> bool: and self._persistent_store_writable ) - def _changes_to_store_data( - self, changes: List[Change] - ) -> Mapping[VersionedDataKind, Mapping[str, dict]]: + def _changes_to_store_data(self, changes: List[Change]) -> Collections: """ - Convert a list of Changes to the format expected by FeatureStore.init(). + Convert a list of Changes to the pre-existing format used by FeatureStore. Args: changes: List of changes to convert Returns: - Mapping suitable for FeatureStore.init() + Mapping suitable for FeatureStore operations. """ - all_data: Dict[VersionedDataKind, Dict[str, dict]] = { + all_data: Collections = { FEATURES: {}, SEGMENTS: {}, } for change in changes: + kind = FEATURES if change.kind == ObjectKind.FLAG else SEGMENTS if change.action == ChangeType.PUT and change.object is not None: - kind = FEATURES if change.kind == ObjectKind.FLAG else SEGMENTS all_data[kind][change.key] = change.object + if change.action == ChangeType.DELETE: + all_data[kind][change.key] = {'key': change.key, 'deleted': True, 'version': change.version} return all_data - def _reset_dependency_tracker( - self, all_data: Mapping[VersionedDataKind, Mapping[str, dict]] - ) -> None: + def _reset_dependency_tracker(self, all_data: Collections) -> None: """Reset dependency tracker with new full data set.""" self._dependency_tracker.reset() for kind, items in all_data.items(): @@ -388,8 +415,8 @@ def _send_change_events(self, affected_items: Set[KindAndKey]) -> None: def _compute_changed_items_for_full_data_set( self, - old_data: Mapping[VersionedDataKind, Mapping[str, dict]], - new_data: Mapping[VersionedDataKind, Mapping[str, dict]], + old_data: Collections, + new_data: Collections, ) -> Set[KindAndKey]: """Compute which items changed between old and new data sets.""" affected_items: Set[KindAndKey] = set() @@ -436,7 +463,7 @@ def commit(self) -> Optional[Exception]: return e return None - def get_active_store(self) -> FeatureStore: + def get_active_store(self) -> ReadOnlyStore: """Get the currently active store for reading data.""" with self._lock: return self._active_store diff --git a/ldclient/impl/dependency_tracker.py b/ldclient/impl/dependency_tracker.py index 1f6286b2..23d6b0d5 100644 --- a/ldclient/impl/dependency_tracker.py +++ b/ldclient/impl/dependency_tracker.py @@ -89,7 +89,7 @@ def compute_dependencies_from(from_kind: VersionedDataKind, from_item: Optional[ @param from_item [LaunchDarkly::Impl::Model::FeatureFlag, LaunchDarkly::Impl::Model::Segment] @return [Set] """ - if from_item is None: + if from_item is None or from_item.get('deleted', False): return set() from_item = from_kind.decode(from_item) if isinstance(from_item, dict) else from_item diff --git a/ldclient/interfaces.py b/ldclient/interfaces.py index cae5c237..307d5545 100644 --- a/ldclient/interfaces.py +++ b/ldclient/interfaces.py @@ -6,7 +6,7 @@ from abc import ABCMeta, abstractmethod, abstractproperty from enum import Enum -from typing import Any, Callable, Mapping, Optional +from typing import Any, Callable, Mapping, Optional, Protocol from ldclient.context import Context from ldclient.impl.listeners import Listeners @@ -39,6 +39,23 @@ class DataStoreMode(Enum): """ +class ReadOnlyStore(Protocol): + """ReadOnlyStore is a read-only interface for a feature store.""" + + @abstractmethod + def get(self, kind: VersionedDataKind, key: str, callback: Callable[[Any], Any] = lambda x: x) -> Any: + raise NotImplementedError + + @abstractmethod + def all(self, kind: VersionedDataKind, callback: Callable[[Any], Any] = lambda x: x) -> Any: + raise NotImplementedError + + @property + @abstractmethod + def initialized(self) -> bool: + raise NotImplementedError + + class FeatureStore: """ Interface for a versioned store for feature flags and related objects received from LaunchDarkly. diff --git a/ldclient/testing/impl/datasystem/test_fdv2_persistence.py b/ldclient/testing/impl/datasystem/test_fdv2_persistence.py index 34cbd4c9..f7898d58 100644 --- a/ldclient/testing/impl/datasystem/test_fdv2_persistence.py +++ b/ldclient/testing/impl/datasystem/test_fdv2_persistence.py @@ -20,7 +20,7 @@ class StubFeatureStore(FeatureStore): def __init__( self, initial_data: Optional[ - Mapping[VersionedDataKind, Mapping[str, Dict[Any, Any]]] + Dict[VersionedDataKind, Dict[str, Dict[Any, Any]]] ] = None, ): self._data: Dict[VersionedDataKind, Dict[str, dict]] = { @@ -433,8 +433,8 @@ def test_persistent_store_delete_operations(): store.apply(delete_changeset, True) # Verify delete was called on persistent store - assert len(persistent_store.delete_calls) > 0 - assert any(call[1] == "deletable-flag" for call in persistent_store.delete_calls) + assert len(persistent_store.upsert_calls) > 0 + assert any(call[1] == "deletable-flag" for call in persistent_store.upsert_calls) def test_data_store_status_provider(): diff --git a/pyproject.toml b/pyproject.toml index 93664d02..1b8a0255 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,7 +33,7 @@ expiringdict = ">=1.1.4" pyRFC3339 = ">=1.0" semver = ">=2.10.2" urllib3 = ">=1.26.0,<3" -launchdarkly-eventsource = ">=1.2.4,<2.0.0" +launchdarkly-eventsource = ">=1.4.0,<2.0.0" redis = { version = ">=2.10.5", optional = true } python-consul = { version = ">=1.0.1", optional = true } From 7453ac5ae097da0c5aca90d080563790bae4d768 Mon Sep 17 00:00:00 2001 From: "Matthew M. Keeler" Date: Fri, 7 Nov 2025 14:56:32 -0500 Subject: [PATCH 07/13] chore: Add support for FDv1 polling synchronizer (#368) --- ldclient/config.py | 2 - ldclient/impl/datasourcev2/polling.py | 131 ++++++++- ldclient/impl/datasourcev2/streaming.py | 31 +- ldclient/impl/datasystem/config.py | 29 ++ ldclient/impl/datasystem/fdv2.py | 6 +- ldclient/impl/datasystem/store.py | 22 +- ldclient/impl/util.py | 11 +- .../test_polling_payload_parsing.py | 210 ++++++++++++++ .../impl/datasystem/test_fdv2_datasystem.py | 265 ++++++++++++++++++ .../impl/datasystem/test_fdv2_persistence.py | 7 +- pyproject.toml | 2 +- 11 files changed, 684 insertions(+), 32 deletions(-) diff --git a/ldclient/config.py b/ldclient/config.py index 7d4a7901..6d690637 100644 --- a/ldclient/config.py +++ b/ldclient/config.py @@ -179,8 +179,6 @@ class DataSystemConfig: data_store: Optional[FeatureStore] = None """The (optional) persistent data store instance.""" - # TODO(fdv2): Implement this synchronizer up and hook it up everywhere. - # TODO(fdv2): Remove this when FDv2 is fully launched fdv1_fallback_synchronizer: Optional[Builder[Synchronizer]] = None """An optional fallback synchronizer that will read from FDv1""" diff --git a/ldclient/impl/datasourcev2/polling.py b/ldclient/impl/datasourcev2/polling.py index 8f867097..a1a67702 100644 --- a/ldclient/impl/datasourcev2/polling.py +++ b/ldclient/impl/datasourcev2/polling.py @@ -14,6 +14,7 @@ import urllib3 from ldclient.config import Config +from ldclient.impl.datasource.feature_requester import LATEST_ALL_URI from ldclient.impl.datasystem import BasisResult, SelectorStore, Update from ldclient.impl.datasystem.protocolv2 import ( Basis, @@ -22,6 +23,8 @@ DeleteObject, EventName, IntentCode, + ObjectKind, + Payload, PutObject, Selector, ServerIntent @@ -43,6 +46,7 @@ DataSourceErrorKind, DataSourceState ) +from ldclient.versioned_data_kind import FEATURES, SEGMENTS POLLING_ENDPOINT = "/sdk/poll" @@ -123,6 +127,15 @@ def sync(self, ss: SelectorStore) -> Generator[Update, None, None]: ), ) + fallback = result.exception.headers.get("X-LD-FD-Fallback") == 'true' + if fallback: + yield Update( + state=DataSourceState.OFF, + error=error_info, + revert_to_fdv1=True + ) + break + status_code = result.exception.status if is_http_error_recoverable(status_code): # TODO(fdv2): Add support for environment ID @@ -158,6 +171,7 @@ def sync(self, ss: SelectorStore) -> Generator[Update, None, None]: state=DataSourceState.VALID, change_set=change_set, environment_id=headers.get("X-LD-EnvID"), + revert_to_fdv1=headers.get('X-LD-FD-Fallback') == 'true' ) if self._event.wait(self._poll_interval): @@ -262,7 +276,7 @@ def fetch(self, selector: Optional[Selector]) -> PollingResult: if response.status >= 400: return _Fail( - f"HTTP error {response}", UnsuccessfulResponseException(response.status) + f"HTTP error {response}", UnsuccessfulResponseException(response.status, response.headers) ) headers = response.headers @@ -375,3 +389,118 @@ def build(self) -> PollingDataSource: return PollingDataSource( poll_interval=self._config.poll_interval, requester=requester ) + + +# pylint: disable=too-few-public-methods +class Urllib3FDv1PollingRequester: + """ + Urllib3PollingRequesterFDv1 is a Requester that uses urllib3 to make HTTP + requests. + """ + + def __init__(self, config: Config): + self._etag = None + self._http = _http_factory(config).create_pool_manager(1, config.base_uri) + self._config = config + self._poll_uri = config.base_uri + LATEST_ALL_URI + + def fetch(self, selector: Optional[Selector]) -> PollingResult: + """ + Fetches the data for the given selector. + Returns a Result containing a tuple of ChangeSet and any request headers, + or an error if the data could not be retrieved. + """ + query_params = {} + if self._config.payload_filter_key is not None: + query_params["filter"] = self._config.payload_filter_key + + uri = self._poll_uri + if len(query_params) > 0: + filter_query = parse.urlencode(query_params) + uri += f"?{filter_query}" + + hdrs = _headers(self._config) + hdrs["Accept-Encoding"] = "gzip" + + if self._etag is not None: + hdrs["If-None-Match"] = self._etag + + response = self._http.request( + "GET", + uri, + headers=hdrs, + timeout=urllib3.Timeout( + connect=self._config.http.connect_timeout, + read=self._config.http.read_timeout, + ), + retries=1, + ) + + if response.status >= 400: + return _Fail( + f"HTTP error {response}", UnsuccessfulResponseException(response.status, response.headers) + ) + + headers = response.headers + + if response.status == 304: + return _Success(value=(ChangeSetBuilder.no_changes(), headers)) + + data = json.loads(response.data.decode("UTF-8")) + etag = headers.get("ETag") + + if etag is not None: + self._etag = etag + + log.debug( + "%s response status:[%d] ETag:[%s]", + uri, + response.status, + etag, + ) + + changeset_result = fdv1_polling_payload_to_changeset(data) + if isinstance(changeset_result, _Success): + return _Success(value=(changeset_result.value, headers)) + + return _Fail( + error=changeset_result.error, + exception=changeset_result.exception, + ) + + +# pylint: disable=too-many-branches,too-many-return-statements +def fdv1_polling_payload_to_changeset(data: dict) -> _Result[ChangeSet, str]: + """ + Converts a fdv1 polling payload into a ChangeSet. + """ + builder = ChangeSetBuilder() + builder.start(IntentCode.TRANSFER_FULL) + selector = Selector.no_selector() + + # FDv1 uses "flags" instead of "features", so we need to map accordingly + # Map FDv1 JSON keys to ObjectKind enum values + kind_mappings = [ + (ObjectKind.FLAG, "flags"), + (ObjectKind.SEGMENT, "segments") + ] + + for kind, fdv1_key in kind_mappings: + kind_data = data.get(fdv1_key) + if kind_data is None: + continue + if not isinstance(kind_data, dict): + return _Fail(error=f"Invalid format: {fdv1_key} is not a dictionary") + + for key in kind_data: + flag_or_segment = kind_data.get(key) + if flag_or_segment is None or not isinstance(flag_or_segment, dict): + return _Fail(error=f"Invalid format: {key} is not a dictionary") + + version = flag_or_segment.get('version') + if version is None: + return _Fail(error=f"Invalid format: {key} does not have a version set") + + builder.add_put(kind, key, version, flag_or_segment) + + return _Success(builder.finish(selector)) diff --git a/ldclient/impl/datasourcev2/streaming.py b/ldclient/impl/datasourcev2/streaming.py index 0f6590dc..5edd0450 100644 --- a/ldclient/impl/datasourcev2/streaming.py +++ b/ldclient/impl/datasourcev2/streaming.py @@ -4,13 +4,12 @@ """ import json -from abc import abstractmethod from time import time -from typing import Callable, Generator, Iterable, Optional, Protocol, Tuple +from typing import Callable, Generator, Optional, Tuple from urllib import parse from ld_eventsource import SSEClient -from ld_eventsource.actions import Action, Event, Fault +from ld_eventsource.actions import Event, Fault, Start from ld_eventsource.config import ( ConnectStrategy, ErrorStrategy, @@ -151,6 +150,15 @@ def sync(self, ss: SelectorStore) -> Generator[Update, None, None]: break continue + if isinstance(action, Start) and action.headers is not None: + fallback = action.headers.get('X-LD-FD-Fallback') == 'true' + if fallback: + yield Update( + state=DataSourceState.OFF, + revert_to_fdv1=True + ) + break + if not isinstance(action, Event): continue @@ -188,11 +196,6 @@ def sync(self, ss: SelectorStore) -> Generator[Update, None, None]: # if update is not None: # self._record_stream_init(False) - # if self._data_source_update_sink is not None: - # self._data_source_update_sink.update_status( - # DataSourceState.VALID, None - # ) - self._sse.close() def stop(self): @@ -288,6 +291,8 @@ def _handle_error(self, error: Exception) -> Tuple[Optional[Update], bool]: If an update is provided, it should be forward upstream, regardless of whether or not we are going to retry this failure. + + The return should be thought of (update, should_continue) """ if not self._running: return (None, False) # don't retry if we've been deliberately stopped @@ -315,12 +320,18 @@ def _handle_error(self, error: Exception) -> Tuple[Optional[Update], bool]: str(error), ) + if error.headers is not None and error.headers.get("X-LD-FD-Fallback") == 'true': + update = Update( + state=DataSourceState.OFF, + error=error_info, + revert_to_fdv1=True + ) + return (update, False) + http_error_message_result = http_error_message( error.status, "stream connection" ) - is_recoverable = is_http_error_recoverable(error.status) - update = Update( state=( DataSourceState.INTERRUPTED diff --git a/ldclient/impl/datasystem/config.py b/ldclient/impl/datasystem/config.py index b179ff9f..d3b34a7a 100644 --- a/ldclient/impl/datasystem/config.py +++ b/ldclient/impl/datasystem/config.py @@ -9,6 +9,7 @@ from ldclient.impl.datasourcev2.polling import ( PollingDataSource, PollingDataSourceBuilder, + Urllib3FDv1PollingRequester, Urllib3PollingRequester ) from ldclient.impl.datasourcev2.streaming import ( @@ -55,6 +56,17 @@ def synchronizers( self._secondary_synchronizer = secondary return self + def fdv1_compatible_synchronizer( + self, + fallback: Builder[Synchronizer] + ) -> "ConfigBuilder": + """ + Configures the SDK with a fallback synchronizer that is compatible with + the Flag Delivery v1 API. + """ + self._fdv1_fallback_synchronizer = fallback + return self + def data_store(self, data_store: FeatureStore, store_mode: DataStoreMode) -> "ConfigBuilder": """ Sets the data store configuration for the data system. @@ -91,6 +103,17 @@ def builder(config: LDConfig) -> PollingDataSource: return builder +def fdv1_fallback_ds_builder() -> Builder[PollingDataSource]: + def builder(config: LDConfig) -> PollingDataSource: + requester = Urllib3FDv1PollingRequester(config) + polling_ds = PollingDataSourceBuilder(config) + polling_ds.requester(requester) + + return polling_ds.build() + + return builder + + def streaming_ds_builder() -> Builder[StreamingDataSource]: def builder(config: LDConfig) -> StreamingDataSource: return StreamingDataSourceBuilder(config).build() @@ -114,10 +137,12 @@ def default() -> ConfigBuilder: polling_builder = polling_ds_builder() streaming_builder = streaming_ds_builder() + fallback = fdv1_fallback_ds_builder() builder = ConfigBuilder() builder.initializers([polling_builder]) builder.synchronizers(streaming_builder, polling_builder) + builder.fdv1_compatible_synchronizer(fallback) return builder @@ -130,9 +155,11 @@ def streaming() -> ConfigBuilder: """ streaming_builder = streaming_ds_builder() + fallback = fdv1_fallback_ds_builder() builder = ConfigBuilder() builder.synchronizers(streaming_builder) + builder.fdv1_compatible_synchronizer(fallback) return builder @@ -145,9 +172,11 @@ def polling() -> ConfigBuilder: """ polling_builder: Builder[Synchronizer] = polling_ds_builder() + fallback = fdv1_fallback_ds_builder() builder = ConfigBuilder() builder.synchronizers(polling_builder) + builder.fdv1_compatible_synchronizer(fallback) return builder diff --git a/ldclient/impl/datasystem/fdv2.py b/ldclient/impl/datasystem/fdv2.py index 8123237b..580aafb2 100644 --- a/ldclient/impl/datasystem/fdv2.py +++ b/ldclient/impl/datasystem/fdv2.py @@ -443,9 +443,13 @@ def _consume_synchronizer_results( # Update status self._data_source_status_provider.update_status(update.state, update.error) + # Check if we should revert to FDv1 immediately + if update.revert_to_fdv1: + return True, True + # Check for OFF state indicating permanent failure if update.state == DataSourceState.OFF: - return True, update.revert_to_fdv1 + return True, False # Check condition periodically current_status = self._data_source_status_provider.status diff --git a/ldclient/impl/datasystem/store.py b/ldclient/impl/datasystem/store.py index 15bc432b..20aea90e 100644 --- a/ldclient/impl/datasystem/store.py +++ b/ldclient/impl/datasystem/store.py @@ -306,13 +306,6 @@ def _set_basis( # Update dependency tracker self._reset_dependency_tracker(collections) - # Send change events if we had listeners - if old_data is not None: - affected_items = self._compute_changed_items_for_full_data_set( - old_data, collections - ) - self._send_change_events(affected_items) - # Update state self._persist = persist self._selector = selector if selector is not None else Selector.no_selector() @@ -324,6 +317,13 @@ def _set_basis( if self._should_persist(): self._persistent_store.init(collections) # type: ignore + # Send change events if we had listeners + if old_data is not None: + affected_items = self._compute_changed_items_for_full_data_set( + old_data, collections + ) + self._send_change_events(affected_items) + def _apply_delta( self, collections: Collections, selector: Optional[Selector], persist: bool ) -> None: @@ -353,10 +353,6 @@ def _apply_delta( affected_items, KindAndKey(kind=kind, key=key) ) - # Send change events - if affected_items: - self._send_change_events(affected_items) - # Update state self._persist = persist self._selector = selector if selector is not None else Selector.no_selector() @@ -368,6 +364,10 @@ def _apply_delta( item = kind_data[i] self._persistent_store.upsert(kind, item) # type: ignore + # Send change events + if affected_items: + self._send_change_events(affected_items) + def _should_persist(self) -> bool: """Returns whether data should be persisted to the persistent store.""" return ( diff --git a/ldclient/impl/util.py b/ldclient/impl/util.py index e60feb9d..81054f4b 100644 --- a/ldclient/impl/util.py +++ b/ldclient/impl/util.py @@ -4,7 +4,7 @@ import time from dataclasses import dataclass from datetime import timedelta -from typing import Any, Generic, Optional, TypeVar, Union +from typing import Any, Dict, Generic, Optional, TypeVar, Union from urllib.parse import urlparse, urlunparse from ldclient.impl.http import _base_headers @@ -117,18 +117,23 @@ def __str__(self, *args, **kwargs): class UnsuccessfulResponseException(Exception): - def __init__(self, status): + def __init__(self, status, headers={}): super(UnsuccessfulResponseException, self).__init__("HTTP error %d" % status) self._status = status + self._headers = headers @property def status(self): return self._status + @property + def headers(self): + return self._headers + def throw_if_unsuccessful_response(resp): if resp.status >= 400: - raise UnsuccessfulResponseException(resp.status) + raise UnsuccessfulResponseException(resp.status, resp.headers) def is_http_error_recoverable(status): diff --git a/ldclient/testing/impl/datasourcev2/test_polling_payload_parsing.py b/ldclient/testing/impl/datasourcev2/test_polling_payload_parsing.py index dae87706..2b483e47 100644 --- a/ldclient/testing/impl/datasourcev2/test_polling_payload_parsing.py +++ b/ldclient/testing/impl/datasourcev2/test_polling_payload_parsing.py @@ -2,6 +2,7 @@ from ldclient.impl.datasourcev2.polling import ( IntentCode, + fdv1_polling_payload_to_changeset, polling_payload_to_changeset ) from ldclient.impl.datasystem.protocolv2 import ChangeType, ObjectKind @@ -151,3 +152,212 @@ def test_fails_if_starts_with_put(): assert ( result.exception.args[0] == "changeset: cannot complete without a server-intent" ) + + +# FDv1 Payload Parsing Tests +def test_fdv1_payload_empty_flags_and_segments(): + """Test that FDv1 payload with empty flags and segments produces empty changeset.""" + data = { + "flags": {}, + "segments": {} + } + result = fdv1_polling_payload_to_changeset(data) + assert isinstance(result, _Success) + + change_set = result.value + assert change_set.intent_code == IntentCode.TRANSFER_FULL + assert len(change_set.changes) == 0 + # FDv1 doesn't use selectors + assert change_set.selector is not None + assert not change_set.selector.is_defined() + + +def test_fdv1_payload_with_single_flag(): + """Test that FDv1 payload with a single flag is parsed correctly.""" + data = { + "flags": { + "test-flag": { + "key": "test-flag", + "version": 1, + "on": True, + "variations": [True, False] + } + }, + "segments": {} + } + result = fdv1_polling_payload_to_changeset(data) + assert isinstance(result, _Success) + + change_set = result.value + assert change_set.intent_code == IntentCode.TRANSFER_FULL + assert len(change_set.changes) == 1 + + change = change_set.changes[0] + assert change.action == ChangeType.PUT + assert change.kind == ObjectKind.FLAG + assert change.key == "test-flag" + assert change.version == 1 + + +def test_fdv1_payload_with_multiple_flags(): + """Test that FDv1 payload with multiple flags is parsed correctly.""" + data = { + "flags": { + "flag-1": {"key": "flag-1", "version": 1, "on": True}, + "flag-2": {"key": "flag-2", "version": 2, "on": False}, + "flag-3": {"key": "flag-3", "version": 3, "on": True} + }, + "segments": {} + } + result = fdv1_polling_payload_to_changeset(data) + assert isinstance(result, _Success) + + change_set = result.value + assert len(change_set.changes) == 3 + + flag_keys = {c.key for c in change_set.changes} + assert flag_keys == {"flag-1", "flag-2", "flag-3"} + + +def test_fdv1_payload_with_single_segment(): + """Test that FDv1 payload with a single segment is parsed correctly.""" + data = { + "flags": {}, + "segments": { + "test-segment": { + "key": "test-segment", + "version": 5, + "included": ["user1", "user2"] + } + } + } + result = fdv1_polling_payload_to_changeset(data) + assert isinstance(result, _Success) + + change_set = result.value + assert len(change_set.changes) == 1 + + change = change_set.changes[0] + assert change.action == ChangeType.PUT + assert change.kind == ObjectKind.SEGMENT + assert change.key == "test-segment" + assert change.version == 5 + + +def test_fdv1_payload_with_flags_and_segments(): + """Test that FDv1 payload with both flags and segments is parsed correctly.""" + data = { + "flags": { + "flag-1": {"key": "flag-1", "version": 1, "on": True}, + "flag-2": {"key": "flag-2", "version": 2, "on": False} + }, + "segments": { + "segment-1": {"key": "segment-1", "version": 10}, + "segment-2": {"key": "segment-2", "version": 20} + } + } + result = fdv1_polling_payload_to_changeset(data) + assert isinstance(result, _Success) + + change_set = result.value + assert len(change_set.changes) == 4 + + flag_changes = [c for c in change_set.changes if c.kind == ObjectKind.FLAG] + segment_changes = [c for c in change_set.changes if c.kind == ObjectKind.SEGMENT] + + assert len(flag_changes) == 2 + assert len(segment_changes) == 2 + + +def test_fdv1_payload_flags_not_dict(): + """Test that FDv1 payload parser fails when flags namespace is not a dict.""" + data = { + "flags": "not a dict" + } + result = fdv1_polling_payload_to_changeset(data) + assert isinstance(result, _Fail) + assert "not a dictionary" in result.error + + +def test_fdv1_payload_segments_not_dict(): + """Test that FDv1 payload parser fails when segments namespace is not a dict.""" + data = { + "flags": {}, + "segments": "not a dict" + } + result = fdv1_polling_payload_to_changeset(data) + assert isinstance(result, _Fail) + assert "not a dictionary" in result.error + + +def test_fdv1_payload_flag_value_not_dict(): + """Test that FDv1 payload parser fails when a flag value is not a dict.""" + data = { + "flags": { + "bad-flag": "not a dict" + } + } + result = fdv1_polling_payload_to_changeset(data) + assert isinstance(result, _Fail) + assert "not a dictionary" in result.error + + +def test_fdv1_payload_flag_missing_version(): + """Test that FDv1 payload parser fails when a flag is missing version.""" + data = { + "flags": { + "no-version-flag": { + "key": "no-version-flag", + "on": True + } + } + } + result = fdv1_polling_payload_to_changeset(data) + assert isinstance(result, _Fail) + assert "does not have a version set" in result.error + + +def test_fdv1_payload_segment_missing_version(): + """Test that FDv1 payload parser fails when a segment is missing version.""" + data = { + "flags": {}, + "segments": { + "no-version-segment": { + "key": "no-version-segment", + "included": [] + } + } + } + result = fdv1_polling_payload_to_changeset(data) + assert isinstance(result, _Fail) + assert "does not have a version set" in result.error + + +def test_fdv1_payload_only_flags_no_segments_key(): + """Test that FDv1 payload works when segments key is missing entirely.""" + data = { + "flags": { + "test-flag": {"key": "test-flag", "version": 1, "on": True} + } + } + result = fdv1_polling_payload_to_changeset(data) + assert isinstance(result, _Success) + + change_set = result.value + assert len(change_set.changes) == 1 + assert change_set.changes[0].key == "test-flag" + + +def test_fdv1_payload_only_segments_no_flags_key(): + """Test that FDv1 payload works when flags key is missing entirely.""" + data = { + "segments": { + "test-segment": {"key": "test-segment", "version": 1} + } + } + result = fdv1_polling_payload_to_changeset(data) + assert isinstance(result, _Success) + + change_set = result.value + assert len(change_set.changes) == 1 + assert change_set.changes[0].key == "test-segment" diff --git a/ldclient/testing/impl/datasystem/test_fdv2_datasystem.py b/ldclient/testing/impl/datasystem/test_fdv2_datasystem.py index 353dfa0a..c1bb6895 100644 --- a/ldclient/testing/impl/datasystem/test_fdv2_datasystem.py +++ b/ldclient/testing/impl/datasystem/test_fdv2_datasystem.py @@ -10,6 +10,7 @@ from ldclient.impl.datasystem.fdv2 import FDv2 from ldclient.integrations.test_datav2 import TestDataV2 from ldclient.interfaces import DataSourceState, DataSourceStatus, FlagChange +from ldclient.versioned_data_kind import FEATURES def test_two_phase_init(): @@ -157,3 +158,267 @@ def listener(status: DataSourceStatus): assert changed.wait(1), "Data system did not shut down in time" assert fdv2.data_source_status_provider.status.state == DataSourceState.OFF + + +def test_fdv2_falls_back_to_fdv1_on_polling_error_with_header(): + """ + Test that FDv2 falls back to FDv1 when polling receives an error response + with the X-LD-FD-Fallback: true header. + """ + # Create a mock primary synchronizer that signals FDv1 fallback + mock_primary: Synchronizer = Mock() + mock_primary.name = "mock-primary" + mock_primary.stop = Mock() + + # Simulate a synchronizer that yields an OFF state with revert_to_fdv1=True + from ldclient.impl.datasystem import Update + mock_primary.sync.return_value = iter([ + Update( + state=DataSourceState.OFF, + revert_to_fdv1=True + ) + ]) + + # Create FDv1 fallback data source with actual data + td_fdv1 = TestDataV2.data_source() + td_fdv1.update(td_fdv1.flag("fdv1-flag").on(True)) + + data_system_config = DataSystemConfig( + initializers=None, + primary_synchronizer=lambda _: mock_primary, + fdv1_fallback_synchronizer=td_fdv1.build_synchronizer, + ) + + changed = Event() + changes: List[FlagChange] = [] + + def listener(flag_change: FlagChange): + changes.append(flag_change) + changed.set() + + set_on_ready = Event() + fdv2 = FDv2(Config(sdk_key="dummy"), data_system_config) + fdv2.flag_tracker.add_listener(listener) + fdv2.start(set_on_ready) + + assert set_on_ready.wait(1), "Data system did not become ready in time" + + # Update flag in FDv1 data source to verify it's being used + td_fdv1.update(td_fdv1.flag("fdv1-flag").on(False)) + assert changed.wait(1), "Flag change listener was not called in time" + + # Verify we got flag changes from FDv1 + assert len(changes) > 0 + assert any(c.key == "fdv1-flag" for c in changes) + + +def test_fdv2_falls_back_to_fdv1_on_polling_success_with_header(): + """ + Test that FDv2 falls back to FDv1 when polling receives a successful response + with the X-LD-FD-Fallback: true header. + """ + # Create a mock primary synchronizer that yields valid data but signals fallback + mock_primary: Synchronizer = Mock() + mock_primary.name = "mock-primary" + mock_primary.stop = Mock() + + from ldclient.impl.datasystem import Update + mock_primary.sync.return_value = iter([ + Update( + state=DataSourceState.VALID, + revert_to_fdv1=True + ) + ]) + + # Create FDv1 fallback data source + td_fdv1 = TestDataV2.data_source() + td_fdv1.update(td_fdv1.flag("fdv1-fallback-flag").on(True)) + + data_system_config = DataSystemConfig( + initializers=None, + primary_synchronizer=lambda _: mock_primary, + fdv1_fallback_synchronizer=td_fdv1.build_synchronizer, + ) + + changed = Event() + changes: List[FlagChange] = [] + count = 0 + + def listener(flag_change: FlagChange): + nonlocal count + count += 1 + changes.append(flag_change) + if count >= 2: + changed.set() + + set_on_ready = Event() + fdv2 = FDv2(Config(sdk_key="dummy"), data_system_config) + fdv2.flag_tracker.add_listener(listener) + fdv2.start(set_on_ready) + + assert set_on_ready.wait(1), "Data system did not become ready in time" + + # Trigger a flag update in FDv1 + td_fdv1.update(td_fdv1.flag("fdv1-fallback-flag").on(False)) + assert changed.wait(1), "Flag change listener was not called in time" + + # Verify FDv1 is active + assert len(changes) > 0 + assert any(c.key == "fdv1-fallback-flag" for c in changes) + + +def test_fdv2_falls_back_to_fdv1_with_initializer(): + """ + Test that FDv2 falls back to FDv1 even when initialized with data, + and that the FDv1 data replaces the initialized data. + """ + # Initialize with some data + td_initializer = TestDataV2.data_source() + td_initializer.update(td_initializer.flag("initial-flag").on(True)) + + # Create mock primary that signals fallback + mock_primary: Synchronizer = Mock() + mock_primary.name = "mock-primary" + mock_primary.stop = Mock() + + from ldclient.impl.datasystem import Update + mock_primary.sync.return_value = iter([ + Update( + state=DataSourceState.OFF, + revert_to_fdv1=True + ) + ]) + + # Create FDv1 fallback with different data + td_fdv1 = TestDataV2.data_source() + td_fdv1.update(td_fdv1.flag("fdv1-replacement-flag").on(True)) + + data_system_config = DataSystemConfig( + initializers=[td_initializer.build_initializer], + primary_synchronizer=lambda _: mock_primary, + fdv1_fallback_synchronizer=td_fdv1.build_synchronizer, + ) + + changed = Event() + changes: List[FlagChange] = [] + + def listener(flag_change: FlagChange): + changes.append(flag_change) + if len(changes) >= 2: + changed.set() + + set_on_ready = Event() + fdv2 = FDv2(Config(sdk_key="dummy"), data_system_config) + fdv2.flag_tracker.add_listener(listener) + fdv2.start(set_on_ready) + + assert set_on_ready.wait(1), "Data system did not become ready in time" + assert changed.wait(2), "Expected flag changes for both initial and fdv1 flags" + + # Verify we got changes for both flags + flag_keys = [c.key for c in changes] + assert "initial-flag" in flag_keys + assert "fdv1-replacement-flag" in flag_keys + + +def test_fdv2_no_fallback_without_header(): + """ + Test that FDv2 does NOT fall back to FDv1 when an error occurs + but the fallback header is not present. + """ + # Create mock primary that fails but doesn't signal fallback + mock_primary: Synchronizer = Mock() + mock_primary.name = "mock-primary" + mock_primary.stop = Mock() + + from ldclient.impl.datasystem import Update + mock_primary.sync.return_value = iter([ + Update( + state=DataSourceState.INTERRUPTED, + revert_to_fdv1=False # No fallback + ) + ]) + + # Create mock secondary + mock_secondary: Synchronizer = Mock() + mock_secondary.name = "mock-secondary" + mock_secondary.stop = Mock() + mock_secondary.sync.return_value = iter([ + Update( + state=DataSourceState.VALID, + revert_to_fdv1=False + ) + ]) + + # Create FDv1 fallback (should not be used) + td_fdv1 = TestDataV2.data_source() + td_fdv1.update(td_fdv1.flag("fdv1-should-not-appear").on(True)) + + data_system_config = DataSystemConfig( + initializers=None, + primary_synchronizer=lambda _: mock_primary, + secondary_synchronizer=lambda _: mock_secondary, + fdv1_fallback_synchronizer=td_fdv1.build_synchronizer, + ) + + set_on_ready = Event() + fdv2 = FDv2(Config(sdk_key="dummy"), data_system_config) + fdv2.start(set_on_ready) + + assert set_on_ready.wait(1), "Data system did not become ready in time" + + # Verify secondary was called (fallback to secondary, not FDv1) + # Give it a moment to process + import time + time.sleep(0.2) + + # The primary should have been called, then secondary + mock_primary.sync.assert_called() + mock_secondary.sync.assert_called() + + +def test_fdv2_stays_on_fdv1_after_fallback(): + """ + Test that once FDv2 falls back to FDv1, it stays on FDv1 and doesn't + attempt to recover to FDv2. + """ + # Create mock primary that signals fallback + mock_primary: Synchronizer = Mock() + mock_primary.name = "mock-primary" + mock_primary.stop = Mock() + + from ldclient.impl.datasystem import Update + mock_primary.sync.return_value = iter([ + Update( + state=DataSourceState.OFF, + revert_to_fdv1=True + ) + ]) + + # Create FDv1 fallback + td_fdv1 = TestDataV2.data_source() + td_fdv1.update(td_fdv1.flag("fdv1-flag").on(True)) + + data_system_config = DataSystemConfig( + initializers=None, + primary_synchronizer=lambda _: mock_primary, + fdv1_fallback_synchronizer=td_fdv1.build_synchronizer, + ) + + set_on_ready = Event() + fdv2 = FDv2(Config(sdk_key="dummy"), data_system_config) + fdv2.start(set_on_ready) + + assert set_on_ready.wait(1), "Data system did not become ready in time" + + # Give it time to settle + import time + time.sleep(0.5) + + # Primary should only be called once (not retried after fallback) + assert mock_primary.sync.call_count == 1 + + # Verify FDv1 is serving data + store = fdv2.store + flag = store.get(FEATURES, "fdv1-flag", lambda x: x) + assert flag is not None diff --git a/ldclient/testing/impl/datasystem/test_fdv2_persistence.py b/ldclient/testing/impl/datasystem/test_fdv2_persistence.py index f7898d58..999f4d07 100644 --- a/ldclient/testing/impl/datasystem/test_fdv2_persistence.py +++ b/ldclient/testing/impl/datasystem/test_fdv2_persistence.py @@ -229,12 +229,13 @@ def test_persistent_store_delta_updates_read_write(): # Set up flag change listener to detect the update flag_changed = Event() - change_count = [0] # Use list to allow modification in nested function + change_count = 0 def listener(flag_change: FlagChange): - change_count[0] += 1 + nonlocal change_count + change_count += 1 if ( - change_count[0] == 2 + change_count == 2 ): # First change is from initial sync, second is our update flag_changed.set() diff --git a/pyproject.toml b/pyproject.toml index 1b8a0255..2a35f126 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,7 +33,7 @@ expiringdict = ">=1.1.4" pyRFC3339 = ">=1.0" semver = ">=2.10.2" urllib3 = ">=1.26.0,<3" -launchdarkly-eventsource = ">=1.4.0,<2.0.0" +launchdarkly-eventsource = ">=1.5.0,<2.0.0" redis = { version = ">=2.10.5", optional = true } python-consul = { version = ">=1.0.1", optional = true } From d688fe955e35bd3609913cbb0e3224c4e7f25dc9 Mon Sep 17 00:00:00 2001 From: "Matthew M. Keeler" Date: Tue, 18 Nov 2025 10:30:04 -0500 Subject: [PATCH 08/13] chore: Add support for diagnostic events (#369) --- ldclient/impl/datasourcev2/streaming.py | 39 ++++++++++++++++--- ldclient/impl/datasystem/__init__.py | 23 ++++++++++- ldclient/impl/datasystem/fdv1.py | 6 +-- ldclient/impl/datasystem/fdv2.py | 21 ++++++---- ldclient/impl/datasystem/protocolv2.py | 13 ++++++- .../test_streaming_synchronizer.py | 4 ++ 6 files changed, 86 insertions(+), 20 deletions(-) diff --git a/ldclient/impl/datasourcev2/streaming.py b/ldclient/impl/datasourcev2/streaming.py index 5edd0450..e8637174 100644 --- a/ldclient/impl/datasourcev2/streaming.py +++ b/ldclient/impl/datasourcev2/streaming.py @@ -18,7 +18,13 @@ from ld_eventsource.errors import HTTPStatusError from ldclient.config import Config -from ldclient.impl.datasystem import SelectorStore, Synchronizer, Update +from ldclient.impl.datasystem import ( + DiagnosticAccumulator, + DiagnosticSource, + SelectorStore, + Synchronizer, + Update +) from ldclient.impl.datasystem.protocolv2 import ( ChangeSetBuilder, DeleteObject, @@ -98,7 +104,7 @@ def query_params() -> dict[str, str]: ) -class StreamingDataSource(Synchronizer): +class StreamingDataSource(Synchronizer, DiagnosticSource): """ StreamingSynchronizer is a specific type of Synchronizer that handles streaming data sources. @@ -112,6 +118,11 @@ def __init__(self, config: Config): self._config = config self._sse: Optional[SSEClient] = None self._running = False + self._diagnostic_accumulator: Optional[DiagnosticAccumulator] = None + self._connection_attempt_start_time: Optional[float] = None + + def set_diagnostic_accumulator(self, diagnostic_accumulator: DiagnosticAccumulator): + self._diagnostic_accumulator = diagnostic_accumulator @property def name(self) -> str: @@ -133,6 +144,7 @@ def sync(self, ss: SelectorStore) -> Generator[Update, None, None]: change_set_builder = ChangeSetBuilder() self._running = True + self._connection_attempt_start_time = time() for action in self._sse.all: if isinstance(action, Fault): @@ -153,6 +165,7 @@ def sync(self, ss: SelectorStore) -> Generator[Update, None, None]: if isinstance(action, Start) and action.headers is not None: fallback = action.headers.get('X-LD-FD-Fallback') == 'true' if fallback: + self._record_stream_init(True) yield Update( state=DataSourceState.OFF, revert_to_fdv1=True @@ -165,6 +178,8 @@ def sync(self, ss: SelectorStore) -> Generator[Update, None, None]: try: update = self._process_message(action, change_set_builder) if update is not None: + self._record_stream_init(False) + self._connection_attempt_start_time = None yield update except json.decoder.JSONDecodeError as e: log.info( @@ -192,10 +207,6 @@ def sync(self, ss: SelectorStore) -> Generator[Update, None, None]: environment_id=None, # TODO(sdk-1410) ) - # TODO(sdk-1408) - # if update is not None: - # self._record_stream_init(False) - self._sse.close() def stop(self): @@ -207,6 +218,12 @@ def stop(self): if self._sse: self._sse.close() + def _record_stream_init(self, failed: bool): + if self._diagnostic_accumulator and self._connection_attempt_start_time: + current_time = int(time() * 1000) + elapsed = current_time - int(self._connection_attempt_start_time * 1000) + self._diagnostic_accumulator.record_stream_init(current_time, elapsed if elapsed >= 0 else 0, failed) + # pylint: disable=too-many-return-statements def _process_message( self, msg: Event, change_set_builder: ChangeSetBuilder @@ -301,6 +318,9 @@ def _handle_error(self, error: Exception) -> Tuple[Optional[Update], bool]: if isinstance(error, json.decoder.JSONDecodeError): log.error("Unexpected error on stream connection: %s, will retry", error) + self._record_stream_init(True) + self._connection_attempt_start_time = time() + \ + self._sse.next_retry_delay # type: ignore update = Update( state=DataSourceState.INTERRUPTED, @@ -313,6 +333,10 @@ def _handle_error(self, error: Exception) -> Tuple[Optional[Update], bool]: return (update, True) if isinstance(error, HTTPStatusError): + self._record_stream_init(True) + self._connection_attempt_start_time = time() + \ + self._sse.next_retry_delay # type: ignore + error_info = DataSourceErrorInfo( DataSourceErrorKind.ERROR_RESPONSE, error.status, @@ -344,6 +368,7 @@ def _handle_error(self, error: Exception) -> Tuple[Optional[Update], bool]: ) if not is_recoverable: + self._connection_attempt_start_time = None log.error(http_error_message_result) self.stop() return (update, False) @@ -352,6 +377,8 @@ def _handle_error(self, error: Exception) -> Tuple[Optional[Update], bool]: return (update, True) log.warning("Unexpected error on stream connection: %s, will retry", error) + self._record_stream_init(True) + self._connection_attempt_start_time = time() + self._sse.next_retry_delay # type: ignore update = Update( state=DataSourceState.INTERRUPTED, diff --git a/ldclient/impl/datasystem/__init__.py b/ldclient/impl/datasystem/__init__.py index ec1fb9e0..1d299944 100644 --- a/ldclient/impl/datasystem/__init__.py +++ b/ldclient/impl/datasystem/__init__.py @@ -7,7 +7,7 @@ from dataclasses import dataclass from enum import Enum from threading import Event -from typing import Callable, Generator, Optional, Protocol +from typing import Generator, Optional, Protocol, runtime_checkable from ldclient.impl.datasystem.protocolv2 import Basis, ChangeSet, Selector from ldclient.impl.util import _Result @@ -151,6 +151,27 @@ def store(self) -> ReadOnlyStore: raise NotImplementedError +class DiagnosticAccumulator(Protocol): + def record_stream_init(self, timestamp, duration, failed): + raise NotImplementedError + + def record_events_in_batch(self, events_in_batch): + raise NotImplementedError + + def create_event_and_reset(self, dropped_events, deduplicated_users): + raise NotImplementedError + + +@runtime_checkable +class DiagnosticSource(Protocol): + @abstractmethod + def set_diagnostic_accumulator(self, diagnostic_accumulator: DiagnosticAccumulator): + """ + Set the diagnostic_accumulator to be used for reporting diagnostic events. + """ + raise NotImplementedError + + class SelectorStore(Protocol): """ SelectorStore represents a component capable of providing Selectors diff --git a/ldclient/impl/datasystem/fdv1.py b/ldclient/impl/datasystem/fdv1.py index 3e57ad34..023c1fc4 100644 --- a/ldclient/impl/datasystem/fdv1.py +++ b/ldclient/impl/datasystem/fdv1.py @@ -13,7 +13,7 @@ DataStoreStatusProviderImpl, DataStoreUpdateSinkImpl ) -from ldclient.impl.datasystem import DataAvailability +from ldclient.impl.datasystem import DataAvailability, DiagnosticAccumulator from ldclient.impl.flag_tracker import FlagTrackerImpl from ldclient.impl.listeners import Listeners from ldclient.impl.stubs import NullUpdateProcessor @@ -78,7 +78,7 @@ def __init__(self, config: Config): self._update_processor: Optional[UpdateProcessor] = None # Diagnostic accumulator provided by client for streaming metrics - self._diagnostic_accumulator = None + self._diagnostic_accumulator: Optional[DiagnosticAccumulator] = None # Track current data availability self._data_availability: DataAvailability = ( @@ -122,7 +122,7 @@ def set_flag_value_eval_fn(self, eval_fn): """ self._flag_tracker_impl = FlagTrackerImpl(self._flag_change_listeners, eval_fn) - def set_diagnostic_accumulator(self, diagnostic_accumulator): + def set_diagnostic_accumulator(self, diagnostic_accumulator: DiagnosticAccumulator): """ Sets the diagnostic accumulator for streaming initialization metrics. This should be called before start() to ensure metrics are collected. diff --git a/ldclient/impl/datasystem/fdv2.py b/ldclient/impl/datasystem/fdv2.py index 580aafb2..41df248b 100644 --- a/ldclient/impl/datasystem/fdv2.py +++ b/ldclient/impl/datasystem/fdv2.py @@ -9,7 +9,12 @@ DataSourceStatusProviderImpl, DataStoreStatusProviderImpl ) -from ldclient.impl.datasystem import DataAvailability, Synchronizer +from ldclient.impl.datasystem import ( + DataAvailability, + DiagnosticAccumulator, + DiagnosticSource, + Synchronizer +) from ldclient.impl.datasystem.store import Store from ldclient.impl.flag_tracker import FlagTrackerImpl from ldclient.impl.listeners import Listeners @@ -173,9 +178,7 @@ def __init__( self._disabled = self._config.offline # Diagnostic accumulator provided by client for streaming metrics - # TODO(fdv2): Either we need to use this, or we need to provide it to - # the streaming synchronizers - self._diagnostic_accumulator = None + self._diagnostic_accumulator: Optional[DiagnosticAccumulator] = None # Set up event listeners self._flag_change_listeners = Listeners() @@ -261,7 +264,7 @@ def stop(self): # Close the store self._store.close() - def set_diagnostic_accumulator(self, diagnostic_accumulator): + def set_diagnostic_accumulator(self, diagnostic_accumulator: DiagnosticAccumulator): """ Sets the diagnostic accumulator for streaming initialization metrics. This should be called before start() to ensure metrics are collected. @@ -334,6 +337,8 @@ def synchronizer_loop(self: 'FDv2'): try: self._lock.lock() primary_sync = self._primary_synchronizer_builder(self._config) + if isinstance(primary_sync, DiagnosticSource) and self._diagnostic_accumulator is not None: + primary_sync.set_diagnostic_accumulator(self._diagnostic_accumulator) self._active_synchronizer = primary_sync self._lock.unlock() @@ -367,6 +372,8 @@ def synchronizer_loop(self: 'FDv2'): self._lock.lock() secondary_sync = self._secondary_synchronizer_builder(self._config) + if isinstance(secondary_sync, DiagnosticSource) and self._diagnostic_accumulator is not None: + secondary_sync.set_diagnostic_accumulator(self._diagnostic_accumulator) log.info("Secondary synchronizer %s is starting", secondary_sync.name) self._active_synchronizer = secondary_sync self._lock.unlock() @@ -386,7 +393,6 @@ def synchronizer_loop(self: 'FDv2'): DataSourceState.OFF, self._data_source_status_provider.status.error ) - # TODO: WE might need to also set that threading.Event here break log.info("Recovery condition met, returning to primary synchronizer") @@ -398,8 +404,7 @@ def synchronizer_loop(self: 'FDv2'): log.error("Error in synchronizer loop: %s", e) finally: # Ensure we always set the ready event when exiting - if not set_on_ready.is_set(): - set_on_ready.set() + set_on_ready.set() self._lock.lock() if self._active_synchronizer is not None: self._active_synchronizer.stop() diff --git a/ldclient/impl/datasystem/protocolv2.py b/ldclient/impl/datasystem/protocolv2.py index 7feb8a81..e61f019e 100644 --- a/ldclient/impl/datasystem/protocolv2.py +++ b/ldclient/impl/datasystem/protocolv2.py @@ -6,10 +6,13 @@ from abc import abstractmethod from dataclasses import dataclass from enum import Enum -from typing import Any, List, Optional, Protocol +from typing import TYPE_CHECKING, Generator, List, Optional, Protocol from ldclient.impl.util import Result +if TYPE_CHECKING: + from ldclient.impl.datasystem import SelectorStore, Update + class EventName(str, Enum): """ @@ -502,7 +505,13 @@ def name(self) -> str: """Returns the name of the initializer.""" raise NotImplementedError - # TODO(fdv2): Need sync method + def sync(self, ss: "SelectorStore") -> "Generator[Update, None, None]": + """ + sync should begin the synchronization process for the data source, yielding + Update objects until the connection is closed or an unrecoverable error + occurs. + """ + raise NotImplementedError def close(self): """ diff --git a/ldclient/testing/impl/datasourcev2/test_streaming_synchronizer.py b/ldclient/testing/impl/datasourcev2/test_streaming_synchronizer.py index f749bff8..90c7037e 100644 --- a/ldclient/testing/impl/datasourcev2/test_streaming_synchronizer.py +++ b/ldclient/testing/impl/datasourcev2/test_streaming_synchronizer.py @@ -53,6 +53,10 @@ def __init__( def all(self) -> Iterable[Action]: return self._events + @property + def next_retry_delay(self): + return 1 + def interrupt(self): pass From 75c9a87697120fbe0cf3bd4f4a38273f4a8362dd Mon Sep 17 00:00:00 2001 From: "Matthew M. Keeler" Date: Tue, 18 Nov 2025 11:18:20 -0500 Subject: [PATCH 09/13] chore: Support x-ld-envid in updates (#370) --- Makefile | 1 + ldclient/impl/datasourcev2/polling.py | 40 ++-- ldclient/impl/datasourcev2/streaming.py | 45 ++-- ldclient/impl/datasystem/config.py | 15 -- ldclient/impl/util.py | 15 +- ldclient/integrations/test_datav2.py | 12 +- .../datasourcev2/test_polling_synchronizer.py | 174 +++++++++++++- .../test_streaming_synchronizer.py | 221 +++++++++++++++++- pyproject.toml | 2 +- 9 files changed, 460 insertions(+), 65 deletions(-) diff --git a/Makefile b/Makefile index 9ee4463d..f2cc2cbb 100644 --- a/Makefile +++ b/Makefile @@ -38,6 +38,7 @@ test-all: install .PHONY: lint lint: #! Run type analysis and linting checks lint: install + @mkdir -p .mypy_cache @poetry run mypy ldclient @poetry run isort --check --atomic ldclient contract-tests @poetry run pycodestyle ldclient contract-tests diff --git a/ldclient/impl/datasourcev2/polling.py b/ldclient/impl/datasourcev2/polling.py index a1a67702..e5415039 100644 --- a/ldclient/impl/datasourcev2/polling.py +++ b/ldclient/impl/datasourcev2/polling.py @@ -32,6 +32,8 @@ from ldclient.impl.http import _http_factory from ldclient.impl.repeating_task import RepeatingTask from ldclient.impl.util import ( + _LD_ENVID_HEADER, + _LD_FD_FALLBACK_HEADER, UnsuccessfulResponseException, _Fail, _headers, @@ -117,6 +119,13 @@ def sync(self, ss: SelectorStore) -> Generator[Update, None, None]: while self._stop.is_set() is False: result = self._requester.fetch(ss.selector()) if isinstance(result, _Fail): + fallback = None + envid = None + + if result.headers is not None: + fallback = result.headers.get(_LD_FD_FALLBACK_HEADER) == 'true' + envid = result.headers.get(_LD_ENVID_HEADER) + if isinstance(result.exception, UnsuccessfulResponseException): error_info = DataSourceErrorInfo( kind=DataSourceErrorKind.ERROR_RESPONSE, @@ -127,28 +136,28 @@ def sync(self, ss: SelectorStore) -> Generator[Update, None, None]: ), ) - fallback = result.exception.headers.get("X-LD-FD-Fallback") == 'true' if fallback: yield Update( state=DataSourceState.OFF, error=error_info, - revert_to_fdv1=True + revert_to_fdv1=True, + environment_id=envid, ) break status_code = result.exception.status if is_http_error_recoverable(status_code): - # TODO(fdv2): Add support for environment ID yield Update( state=DataSourceState.INTERRUPTED, error=error_info, + environment_id=envid, ) continue - # TODO(fdv2): Add support for environment ID yield Update( state=DataSourceState.OFF, error=error_info, + environment_id=envid, ) break @@ -159,19 +168,18 @@ def sync(self, ss: SelectorStore) -> Generator[Update, None, None]: message=result.error, ) - # TODO(fdv2): Go has a designation here to handle JSON decoding separately. - # TODO(fdv2): Add support for environment ID yield Update( state=DataSourceState.INTERRUPTED, error=error_info, + environment_id=envid, ) else: (change_set, headers) = result.value yield Update( state=DataSourceState.VALID, change_set=change_set, - environment_id=headers.get("X-LD-EnvID"), - revert_to_fdv1=headers.get('X-LD-FD-Fallback') == 'true' + environment_id=headers.get(_LD_ENVID_HEADER), + revert_to_fdv1=headers.get(_LD_FD_FALLBACK_HEADER) == 'true' ) if self._event.wait(self._poll_interval): @@ -208,7 +216,7 @@ def _poll(self, ss: SelectorStore) -> BasisResult: (change_set, headers) = result.value - env_id = headers.get("X-LD-EnvID") + env_id = headers.get(_LD_ENVID_HEADER) if not isinstance(env_id, str): env_id = None @@ -273,14 +281,14 @@ def fetch(self, selector: Optional[Selector]) -> PollingResult: ), retries=1, ) + headers = response.headers if response.status >= 400: return _Fail( - f"HTTP error {response}", UnsuccessfulResponseException(response.status, response.headers) + f"HTTP error {response}", UnsuccessfulResponseException(response.status), + headers=headers, ) - headers = response.headers - if response.status == 304: return _Success(value=(ChangeSetBuilder.no_changes(), headers)) @@ -304,6 +312,7 @@ def fetch(self, selector: Optional[Selector]) -> PollingResult: return _Fail( error=changeset_result.error, exception=changeset_result.exception, + headers=headers, # type: ignore ) @@ -436,13 +445,13 @@ def fetch(self, selector: Optional[Selector]) -> PollingResult: retries=1, ) + headers = response.headers if response.status >= 400: return _Fail( - f"HTTP error {response}", UnsuccessfulResponseException(response.status, response.headers) + f"HTTP error {response}", UnsuccessfulResponseException(response.status), + headers=headers ) - headers = response.headers - if response.status == 304: return _Success(value=(ChangeSetBuilder.no_changes(), headers)) @@ -466,6 +475,7 @@ def fetch(self, selector: Optional[Selector]) -> PollingResult: return _Fail( error=changeset_result.error, exception=changeset_result.exception, + headers=headers, ) diff --git a/ldclient/impl/datasourcev2/streaming.py b/ldclient/impl/datasourcev2/streaming.py index e8637174..eab7fa8d 100644 --- a/ldclient/impl/datasourcev2/streaming.py +++ b/ldclient/impl/datasourcev2/streaming.py @@ -38,6 +38,8 @@ ) from ldclient.impl.http import HTTPFactory, _http_factory from ldclient.impl.util import ( + _LD_ENVID_HEADER, + _LD_FD_FALLBACK_HEADER, http_error_message, is_http_error_recoverable, log @@ -58,7 +60,6 @@ STREAMING_ENDPOINT = "/sdk/stream" - SseClientBuilder = Callable[[Config, SelectorStore], SSEClient] @@ -146,6 +147,7 @@ def sync(self, ss: SelectorStore) -> Generator[Update, None, None]: self._running = True self._connection_attempt_start_time = time() + envid = None for action in self._sse.all: if isinstance(action, Fault): # If the SSE client detects the stream has closed, then it will @@ -154,7 +156,10 @@ def sync(self, ss: SelectorStore) -> Generator[Update, None, None]: if action.error is None: continue - (update, should_continue) = self._handle_error(action.error) + if action.headers is not None: + envid = action.headers.get(_LD_ENVID_HEADER, envid) + + (update, should_continue) = self._handle_error(action.error, envid) if update is not None: yield update @@ -163,12 +168,15 @@ def sync(self, ss: SelectorStore) -> Generator[Update, None, None]: continue if isinstance(action, Start) and action.headers is not None: - fallback = action.headers.get('X-LD-FD-Fallback') == 'true' + fallback = action.headers.get(_LD_FD_FALLBACK_HEADER) == 'true' + envid = action.headers.get(_LD_ENVID_HEADER, envid) + if fallback: self._record_stream_init(True) yield Update( state=DataSourceState.OFF, - revert_to_fdv1=True + revert_to_fdv1=True, + environment_id=envid, ) break @@ -176,7 +184,7 @@ def sync(self, ss: SelectorStore) -> Generator[Update, None, None]: continue try: - update = self._process_message(action, change_set_builder) + update = self._process_message(action, change_set_builder, envid) if update is not None: self._record_stream_init(False) self._connection_attempt_start_time = None @@ -187,7 +195,7 @@ def sync(self, ss: SelectorStore) -> Generator[Update, None, None]: ) self._sse.interrupt() - (update, should_continue) = self._handle_error(e) + (update, should_continue) = self._handle_error(e, envid) if update is not None: yield update if not should_continue: @@ -204,7 +212,7 @@ def sync(self, ss: SelectorStore) -> Generator[Update, None, None]: DataSourceErrorKind.UNKNOWN, 0, time(), str(e) ), revert_to_fdv1=False, - environment_id=None, # TODO(sdk-1410) + environment_id=envid, ) self._sse.close() @@ -226,7 +234,7 @@ def _record_stream_init(self, failed: bool): # pylint: disable=too-many-return-statements def _process_message( - self, msg: Event, change_set_builder: ChangeSetBuilder + self, msg: Event, change_set_builder: ChangeSetBuilder, envid: Optional[str] ) -> Optional[Update]: """ Processes a single message from the SSE stream and returns an Update @@ -247,7 +255,7 @@ def _process_message( change_set_builder.expect_changes() return Update( state=DataSourceState.VALID, - environment_id=None, # TODO(sdk-1410) + environment_id=envid, ) return None @@ -293,13 +301,13 @@ def _process_message( return Update( state=DataSourceState.VALID, change_set=change_set, - environment_id=None, # TODO(sdk-1410) + environment_id=envid, ) log.info("Unexpected event found in stream: %s", msg.event) return None - def _handle_error(self, error: Exception) -> Tuple[Optional[Update], bool]: + def _handle_error(self, error: Exception, envid: Optional[str]) -> Tuple[Optional[Update], bool]: """ This method handles errors that occur during the streaming process. @@ -328,7 +336,7 @@ def _handle_error(self, error: Exception) -> Tuple[Optional[Update], bool]: DataSourceErrorKind.INVALID_DATA, 0, time(), str(error) ), revert_to_fdv1=False, - environment_id=None, # TODO(sdk-1410) + environment_id=envid, ) return (update, True) @@ -344,11 +352,15 @@ def _handle_error(self, error: Exception) -> Tuple[Optional[Update], bool]: str(error), ) - if error.headers is not None and error.headers.get("X-LD-FD-Fallback") == 'true': + if envid is None and error.headers is not None: + envid = error.headers.get(_LD_ENVID_HEADER) + + if error.headers is not None and error.headers.get(_LD_FD_FALLBACK_HEADER) == 'true': update = Update( state=DataSourceState.OFF, error=error_info, - revert_to_fdv1=True + revert_to_fdv1=True, + environment_id=envid, ) return (update, False) @@ -364,7 +376,7 @@ def _handle_error(self, error: Exception) -> Tuple[Optional[Update], bool]: ), error=error_info, revert_to_fdv1=False, - environment_id=None, # TODO(sdk-1410) + environment_id=envid, ) if not is_recoverable: @@ -386,7 +398,7 @@ def _handle_error(self, error: Exception) -> Tuple[Optional[Update], bool]: DataSourceErrorKind.UNKNOWN, 0, time(), str(error) ), revert_to_fdv1=False, - environment_id=None, # TODO(sdk-1410) + environment_id=envid, ) # no stacktrace here because, for a typical connection error, it'll # just be a lengthy tour of urllib3 internals @@ -411,5 +423,4 @@ def __init__(self, config: Config): def build(self) -> StreamingDataSource: """Builds a StreamingDataSource instance with the configured parameters.""" - # TODO(fdv2): Add in the other controls here. return StreamingDataSource(self._config) diff --git a/ldclient/impl/datasystem/config.py b/ldclient/impl/datasystem/config.py index d3b34a7a..eadc6f0e 100644 --- a/ldclient/impl/datasystem/config.py +++ b/ldclient/impl/datasystem/config.py @@ -210,18 +210,3 @@ def persistent_store(store: FeatureStore) -> ConfigBuilder: although it will keep it up-to-date. """ return default().data_store(store, DataStoreMode.READ_WRITE) - - -# TODO(fdv2): Implement these methods -# -# WithEndpoints configures the data system with custom endpoints for -# LaunchDarkly's streaming and polling synchronizers. This method is not -# necessary for most use-cases, but can be useful for testing or custom -# network configurations. -# -# Any endpoint that is not specified (empty string) will be treated as the -# default LaunchDarkly SaaS endpoint for that service. - -# WithRelayProxyEndpoints configures the data system with a single endpoint -# for LaunchDarkly's streaming and polling synchronizers. The endpoint -# should be Relay Proxy's base URI, for example http://localhost:8123. diff --git a/ldclient/impl/util.py b/ldclient/impl/util.py index 81054f4b..54caf9de 100644 --- a/ldclient/impl/util.py +++ b/ldclient/impl/util.py @@ -4,7 +4,7 @@ import time from dataclasses import dataclass from datetime import timedelta -from typing import Any, Dict, Generic, Optional, TypeVar, Union +from typing import Any, Dict, Generic, Mapping, Optional, TypeVar, Union from urllib.parse import urlparse, urlunparse from ldclient.impl.http import _base_headers @@ -35,6 +35,9 @@ def timedelta_millis(delta: timedelta) -> float: # Compiled regex pattern for valid characters in application values and SDK keys _VALID_CHARACTERS_REGEX = re.compile(r"[^a-zA-Z0-9._-]") +_LD_ENVID_HEADER = 'X-LD-EnvID' +_LD_FD_FALLBACK_HEADER = 'X-LD-FD-Fallback' + def validate_application_info(application: dict, logger: logging.Logger) -> dict: return { @@ -117,23 +120,18 @@ def __str__(self, *args, **kwargs): class UnsuccessfulResponseException(Exception): - def __init__(self, status, headers={}): + def __init__(self, status): super(UnsuccessfulResponseException, self).__init__("HTTP error %d" % status) self._status = status - self._headers = headers @property def status(self): return self._status - @property - def headers(self): - return self._headers - def throw_if_unsuccessful_response(resp): if resp.status >= 400: - raise UnsuccessfulResponseException(resp.status, resp.headers) + raise UnsuccessfulResponseException(resp.status) def is_http_error_recoverable(status): @@ -290,6 +288,7 @@ class _Success(Generic[T]): class _Fail(Generic[E]): error: E exception: Optional[Exception] = None + headers: Optional[Mapping[str, Any]] = None # TODO(breaking): Replace the above Result class with an improved generic diff --git a/ldclient/integrations/test_datav2.py b/ldclient/integrations/test_datav2.py index 744264f2..a2da52db 100644 --- a/ldclient/integrations/test_datav2.py +++ b/ldclient/integrations/test_datav2.py @@ -551,17 +551,21 @@ class TestDataV2: :: from ldclient.impl.datasystem import config as datasystem_config + from ldclient.integrations.test_datav2 import TestDataV2 + td = TestDataV2.data_source() td.update(td.flag('flag-key-1').variation_for_all(True)) # Configure the data system with TestDataV2 as both initializer and synchronizer data_config = datasystem_config.custom() - data_config.initializers([lambda: td.build_initializer()]) - data_config.synchronizers(lambda: td.build_synchronizer()) + data_config.initializers([td.build_initializer]) + data_config.synchronizers(td.build_synchronizer) - # TODO(fdv2): This will be integrated with the main Config in a future version - # For now, TestDataV2 is primarily intended for unit testing scenarios + config = Config( + sdk_key, + datasystem_config=data_config.build(), + ) # flags can be updated at any time: td.update(td.flag('flag-key-1'). diff --git a/ldclient/testing/impl/datasourcev2/test_polling_synchronizer.py b/ldclient/testing/impl/datasourcev2/test_polling_synchronizer.py index 3410a1e6..7aa3686e 100644 --- a/ldclient/testing/impl/datasourcev2/test_polling_synchronizer.py +++ b/ldclient/testing/impl/datasourcev2/test_polling_synchronizer.py @@ -20,7 +20,13 @@ Selector, ServerIntent ) -from ldclient.impl.util import UnsuccessfulResponseException, _Fail, _Success +from ldclient.impl.util import ( + _LD_ENVID_HEADER, + _LD_FD_FALLBACK_HEADER, + UnsuccessfulResponseException, + _Fail, + _Success +) from ldclient.interfaces import DataSourceErrorKind, DataSourceState from ldclient.testing.mock_components import MockSelectorStore @@ -304,3 +310,169 @@ def test_unrecoverable_error_shuts_down(): assert False, "Expected StopIteration" except StopIteration: pass + + +def test_envid_from_success_headers(): + """Test that environment ID is captured from successful polling response headers""" + change_set = ChangeSetBuilder.no_changes() + headers = {_LD_ENVID_HEADER: 'test-env-polling-123'} + polling_result: PollingResult = _Success(value=(change_set, headers)) + + synchronizer = PollingDataSource( + poll_interval=0.01, requester=ListBasedRequester(results=iter([polling_result])) + ) + + valid = next(synchronizer.sync(MockSelectorStore(Selector.no_selector()))) + + assert valid.state == DataSourceState.VALID + assert valid.error is None + assert valid.revert_to_fdv1 is False + assert valid.environment_id == 'test-env-polling-123' + + +def test_envid_from_success_with_changeset(): + """Test that environment ID is captured from polling response with actual changes""" + builder = ChangeSetBuilder() + builder.start(intent=IntentCode.TRANSFER_FULL) + builder.add_put( + version=100, kind=ObjectKind.FLAG, key="flag-key", obj={"key": "flag-key"} + ) + change_set = builder.finish(selector=Selector(state="p:SOMETHING:300", version=300)) + headers = {_LD_ENVID_HEADER: 'test-env-456'} + polling_result: PollingResult = _Success(value=(change_set, headers)) + + synchronizer = PollingDataSource( + poll_interval=0.01, requester=ListBasedRequester(results=iter([polling_result])) + ) + valid = next(synchronizer.sync(MockSelectorStore(Selector.no_selector()))) + + assert valid.state == DataSourceState.VALID + assert valid.environment_id == 'test-env-456' + assert valid.change_set is not None + assert len(valid.change_set.changes) == 1 + + +def test_envid_from_fallback_headers(): + """Test that environment ID is captured when fallback header is present on success""" + change_set = ChangeSetBuilder.no_changes() + headers = { + _LD_ENVID_HEADER: 'test-env-fallback', + _LD_FD_FALLBACK_HEADER: 'true' + } + polling_result: PollingResult = _Success(value=(change_set, headers)) + + synchronizer = PollingDataSource( + poll_interval=0.01, requester=ListBasedRequester(results=iter([polling_result])) + ) + + valid = next(synchronizer.sync(MockSelectorStore(Selector.no_selector()))) + + assert valid.state == DataSourceState.VALID + assert valid.revert_to_fdv1 is True + assert valid.environment_id == 'test-env-fallback' + + +def test_envid_from_error_headers_recoverable(): + """Test that environment ID is captured from error response headers for recoverable errors""" + builder = ChangeSetBuilder() + builder.start(intent=IntentCode.TRANSFER_FULL) + builder.add_delete(version=101, kind=ObjectKind.FLAG, key="flag-key") + change_set = builder.finish(selector=Selector(state="p:SOMETHING:300", version=300)) + headers_success = {_LD_ENVID_HEADER: 'test-env-success'} + polling_result: PollingResult = _Success(value=(change_set, headers_success)) + + headers_error = {_LD_ENVID_HEADER: 'test-env-408'} + _failure = _Fail( + error="error for test", + exception=UnsuccessfulResponseException(status=408), + headers=headers_error + ) + + synchronizer = PollingDataSource( + poll_interval=0.01, + requester=ListBasedRequester(results=iter([_failure, polling_result])), + ) + sync = synchronizer.sync(MockSelectorStore(Selector.no_selector())) + interrupted = next(sync) + valid = next(sync) + + assert interrupted.state == DataSourceState.INTERRUPTED + assert interrupted.environment_id == 'test-env-408' + assert interrupted.error is not None + assert interrupted.error.status_code == 408 + + assert valid.state == DataSourceState.VALID + assert valid.environment_id == 'test-env-success' + + +def test_envid_from_error_headers_unrecoverable(): + """Test that environment ID is captured from error response headers for unrecoverable errors""" + headers_error = {_LD_ENVID_HEADER: 'test-env-401'} + _failure = _Fail( + error="error for test", + exception=UnsuccessfulResponseException(status=401), + headers=headers_error + ) + + synchronizer = PollingDataSource( + poll_interval=0.01, + requester=ListBasedRequester(results=iter([_failure])), + ) + sync = synchronizer.sync(MockSelectorStore(Selector.no_selector())) + off = next(sync) + + assert off.state == DataSourceState.OFF + assert off.environment_id == 'test-env-401' + assert off.error is not None + assert off.error.status_code == 401 + + +def test_envid_from_error_with_fallback(): + """Test that environment ID and fallback are captured from error response""" + headers_error = { + _LD_ENVID_HEADER: 'test-env-503', + _LD_FD_FALLBACK_HEADER: 'true' + } + _failure = _Fail( + error="error for test", + exception=UnsuccessfulResponseException(status=503), + headers=headers_error + ) + + synchronizer = PollingDataSource( + poll_interval=0.01, + requester=ListBasedRequester(results=iter([_failure])), + ) + sync = synchronizer.sync(MockSelectorStore(Selector.no_selector())) + off = next(sync) + + assert off.state == DataSourceState.OFF + assert off.revert_to_fdv1 is True + assert off.environment_id == 'test-env-503' + + +def test_envid_from_generic_error_with_headers(): + """Test that environment ID is captured from generic errors with headers""" + builder = ChangeSetBuilder() + builder.start(intent=IntentCode.TRANSFER_FULL) + change_set = builder.finish(selector=Selector(state="p:SOMETHING:300", version=300)) + headers_success = {} + polling_result: PollingResult = _Success(value=(change_set, headers_success)) + + headers_error = {_LD_ENVID_HEADER: 'test-env-generic'} + _failure = _Fail(error="generic error for test", headers=headers_error) + + synchronizer = PollingDataSource( + poll_interval=0.01, + requester=ListBasedRequester(results=iter([_failure, polling_result])), + ) + sync = synchronizer.sync(MockSelectorStore(Selector.no_selector())) + interrupted = next(sync) + valid = next(sync) + + assert interrupted.state == DataSourceState.INTERRUPTED + assert interrupted.environment_id == 'test-env-generic' + assert interrupted.error is not None + assert interrupted.error.kind == DataSourceErrorKind.NETWORK_ERROR + + assert valid.state == DataSourceState.VALID diff --git a/ldclient/testing/impl/datasourcev2/test_streaming_synchronizer.py b/ldclient/testing/impl/datasourcev2/test_streaming_synchronizer.py index 90c7037e..c581e785 100644 --- a/ldclient/testing/impl/datasourcev2/test_streaming_synchronizer.py +++ b/ldclient/testing/impl/datasourcev2/test_streaming_synchronizer.py @@ -6,7 +6,7 @@ from typing import Iterable, List, Optional import pytest -from ld_eventsource.actions import Action +from ld_eventsource.actions import Action, Start from ld_eventsource.http import HTTPStatusError from ld_eventsource.sse_client import Event, Fault @@ -30,6 +30,7 @@ Selector, ServerIntent ) +from ldclient.impl.util import _LD_ENVID_HEADER, _LD_FD_FALLBACK_HEADER from ldclient.interfaces import DataSourceErrorKind, DataSourceState from ldclient.testing.mock_components import MockSelectorStore @@ -416,10 +417,12 @@ def test_invalid_json_decoding(events): # pylint: disable=redefined-outer-name def test_stops_on_unrecoverable_status_code( events, ): # pylint: disable=redefined-outer-name + error = HTTPStatusError(401) + fault = Fault(error=error) builder = list_sse_client( [ # This will generate an error but the stream should continue - Fault(error=HTTPStatusError(401)), + fault, # We send these valid combinations to ensure the stream is NOT # being processed after the 401. events[EventName.SERVER_INTENT], @@ -445,12 +448,18 @@ def test_stops_on_unrecoverable_status_code( def test_continues_on_recoverable_status_code( events, ): # pylint: disable=redefined-outer-name + error1 = HTTPStatusError(400) + fault1 = Fault(error=error1) + + error2 = HTTPStatusError(408) + fault2 = Fault(error=error2) + builder = list_sse_client( [ # This will generate an error but the stream should continue - Fault(error=HTTPStatusError(400)), + fault1, events[EventName.SERVER_INTENT], - Fault(error=HTTPStatusError(408)), + fault2, # We send these valid combinations to ensure the stream will # continue to be processed. events[EventName.SERVER_INTENT], @@ -478,3 +487,207 @@ def test_continues_on_recoverable_status_code( assert updates[2].change_set.selector.version == 300 assert updates[2].change_set.selector.state == "p:SOMETHING:300" assert updates[2].change_set.intent_code == IntentCode.TRANSFER_FULL + + +def test_envid_from_start_action(events): # pylint: disable=redefined-outer-name + """Test that environment ID is captured from Start action headers""" + start_action = Start(headers={_LD_ENVID_HEADER: 'test-env-123'}) + + builder = list_sse_client( + [ + start_action, + events[EventName.SERVER_INTENT], + events[EventName.PAYLOAD_TRANSFERRED], + ] + ) + + synchronizer = StreamingDataSource(Config(sdk_key="key")) + synchronizer._sse_client_builder = builder + updates = list(synchronizer.sync(MockSelectorStore(Selector.no_selector()))) + + assert len(updates) == 1 + assert updates[0].state == DataSourceState.VALID + assert updates[0].environment_id == 'test-env-123' + + +def test_envid_not_cleared_from_next_start(events): # pylint: disable=redefined-outer-name + """Test that environment ID is captured from Start action headers""" + start_action_with_headers = Start(headers={_LD_ENVID_HEADER: 'test-env-123'}) + start_action_without_headers = Start() + + builder = list_sse_client( + [ + start_action_with_headers, + events[EventName.SERVER_INTENT], + events[EventName.PAYLOAD_TRANSFERRED], + start_action_without_headers, + events[EventName.SERVER_INTENT], + events[EventName.PAYLOAD_TRANSFERRED], + ] + ) + + synchronizer = StreamingDataSource(Config(sdk_key="key")) + synchronizer._sse_client_builder = builder + updates = list(synchronizer.sync(MockSelectorStore(Selector.no_selector()))) + + assert len(updates) == 2 + assert updates[0].state == DataSourceState.VALID + assert updates[0].environment_id == 'test-env-123' + + assert updates[1].state == DataSourceState.VALID + assert updates[1].environment_id == 'test-env-123' + + +def test_envid_preserved_across_events(events): # pylint: disable=redefined-outer-name + """Test that environment ID is preserved across multiple events after being set on Start""" + start_action = Start(headers={_LD_ENVID_HEADER: 'test-env-456'}) + + builder = list_sse_client( + [ + start_action, + events[EventName.SERVER_INTENT], + events[EventName.PUT_OBJECT], + events[EventName.PAYLOAD_TRANSFERRED], + ] + ) + + synchronizer = StreamingDataSource(Config(sdk_key="key")) + synchronizer._sse_client_builder = builder + updates = list(synchronizer.sync(MockSelectorStore(Selector.no_selector()))) + + assert len(updates) == 1 + assert updates[0].state == DataSourceState.VALID + assert updates[0].environment_id == 'test-env-456' + assert updates[0].change_set is not None + assert len(updates[0].change_set.changes) == 1 + + +def test_envid_from_fallback_header(): + """Test that environment ID is captured when fallback header is present""" + start_action = Start(headers={_LD_ENVID_HEADER: 'test-env-fallback', _LD_FD_FALLBACK_HEADER: 'true'}) + + builder = list_sse_client([start_action]) + + synchronizer = StreamingDataSource(Config(sdk_key="key")) + synchronizer._sse_client_builder = builder + updates = list(synchronizer.sync(MockSelectorStore(Selector.no_selector()))) + + assert len(updates) == 1 + assert updates[0].state == DataSourceState.OFF + assert updates[0].revert_to_fdv1 is True + assert updates[0].environment_id == 'test-env-fallback' + + +def test_envid_from_fault_action(): + """Test that environment ID is captured from Fault action headers""" + error = HTTPStatusError(401, headers={_LD_ENVID_HEADER: 'test-env-fault'}) + fault_action = Fault(error=error) + + builder = list_sse_client([fault_action]) + + synchronizer = StreamingDataSource(Config(sdk_key="key")) + synchronizer._sse_client_builder = builder + updates = list(synchronizer.sync(MockSelectorStore(Selector.no_selector()))) + + assert len(updates) == 1 + assert updates[0].state == DataSourceState.OFF + assert updates[0].environment_id == 'test-env-fault' + assert updates[0].error is not None + assert updates[0].error.status_code == 401 + + +def test_envid_not_cleared_from_next_error(): + """Test that environment ID is captured from Fault action headers""" + error_with_headers_ = HTTPStatusError(408, headers={_LD_ENVID_HEADER: 'test-env-fault'}) + error_without_headers_ = HTTPStatusError(401) + fault_action_with_headers = Fault(error=error_with_headers_) + fault_action_without_headers = Fault(error=error_without_headers_) + + builder = list_sse_client([fault_action_with_headers, fault_action_without_headers]) + + synchronizer = StreamingDataSource(Config(sdk_key="key")) + synchronizer._sse_client_builder = builder + updates = list(synchronizer.sync(MockSelectorStore(Selector.no_selector()))) + + assert len(updates) == 2 + assert updates[0].state == DataSourceState.INTERRUPTED + assert updates[0].environment_id == 'test-env-fault' + assert updates[0].error is not None + assert updates[0].error.status_code == 408 + + assert updates[1].state == DataSourceState.OFF + assert updates[1].environment_id == 'test-env-fault' + assert updates[1].error is not None + assert updates[1].error.status_code == 401 + + +def test_envid_from_fault_with_fallback(): + """Test that environment ID and fallback are captured from Fault action""" + error = HTTPStatusError(503, headers={_LD_ENVID_HEADER: 'test-env-503', _LD_FD_FALLBACK_HEADER: 'true'}) + fault_action = Fault(error=error) + + builder = list_sse_client([fault_action]) + + synchronizer = StreamingDataSource(Config(sdk_key="key")) + synchronizer._sse_client_builder = builder + updates = list(synchronizer.sync(MockSelectorStore(Selector.no_selector()))) + + assert len(updates) == 1 + assert updates[0].state == DataSourceState.OFF + assert updates[0].revert_to_fdv1 is True + assert updates[0].environment_id == 'test-env-503' + + +def test_envid_from_recoverable_fault(events): # pylint: disable=redefined-outer-name + """Test that environment ID is captured from recoverable Fault and preserved in subsequent events""" + error = HTTPStatusError(400, headers={_LD_ENVID_HEADER: 'test-env-400'}) + fault_action = Fault(error=error) + + builder = list_sse_client( + [ + fault_action, + events[EventName.SERVER_INTENT], + events[EventName.PAYLOAD_TRANSFERRED], + ] + ) + + synchronizer = StreamingDataSource(Config(sdk_key="key")) + synchronizer._sse_client_builder = builder + updates = list(synchronizer.sync(MockSelectorStore(Selector.no_selector()))) + + assert len(updates) == 2 + # First update from the fault + assert updates[0].state == DataSourceState.INTERRUPTED + assert updates[0].environment_id == 'test-env-400' + + # Second update should preserve the envid + assert updates[1].state == DataSourceState.VALID + assert updates[1].environment_id == 'test-env-400' + + +def test_envid_missing_when_no_headers(): + """Test that environment ID is None when no headers are present""" + start_action = Start() + + server_intent = ServerIntent( + payload=Payload( + id="id", + target=300, + code=IntentCode.TRANSFER_NONE, + reason="up-to-date", + ) + ) + intent_event = Event( + event=EventName.SERVER_INTENT, + data=json.dumps(server_intent.to_dict()), + ) + + builder = list_sse_client([start_action, intent_event]) + + synchronizer = StreamingDataSource(Config(sdk_key="key")) + synchronizer._sse_client_builder = builder + updates = list(synchronizer.sync(MockSelectorStore(Selector.no_selector()))) + + assert len(updates) == 1 + assert updates[0].state == DataSourceState.VALID + assert updates[0].environment_id is None diff --git a/pyproject.toml b/pyproject.toml index 2a35f126..7871a387 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -50,7 +50,7 @@ test-filesource = ["pyyaml", "watchdog"] [tool.poetry.group.dev.dependencies] mock = ">=2.0.0" -pytest = ">=2.8" +pytest = "^8.0.0" redis = ">=2.10.5,<5.0.0" boto3 = ">=1.9.71,<2.0.0" coverage = ">=4.4" From 3d4eeb9d9ab7cf9859b0e4c9d745a4b2d2e30058 Mon Sep 17 00:00:00 2001 From: "Matthew M. Keeler" Date: Tue, 18 Nov 2025 13:06:30 -0500 Subject: [PATCH 10/13] chore: Support file data source as initializer and synchronizer (#371) --- .../integrations/files/file_data_sourcev2.py | 428 ++++++++++++++++ ldclient/integrations/__init__.py | 64 ++- .../integrations/test_file_data_sourcev2.py | 469 ++++++++++++++++++ 3 files changed, 960 insertions(+), 1 deletion(-) create mode 100644 ldclient/impl/integrations/files/file_data_sourcev2.py create mode 100644 ldclient/testing/integrations/test_file_data_sourcev2.py diff --git a/ldclient/impl/integrations/files/file_data_sourcev2.py b/ldclient/impl/integrations/files/file_data_sourcev2.py new file mode 100644 index 00000000..c8e152b7 --- /dev/null +++ b/ldclient/impl/integrations/files/file_data_sourcev2.py @@ -0,0 +1,428 @@ +import json +import os +import threading +import traceback +from queue import Empty, Queue +from typing import Generator + +from ldclient.impl.datasystem import BasisResult, SelectorStore, Update +from ldclient.impl.datasystem.protocolv2 import ( + Basis, + ChangeSetBuilder, + IntentCode, + ObjectKind, + Selector +) +from ldclient.impl.repeating_task import RepeatingTask +from ldclient.impl.util import _Fail, _Success, current_time_millis, log +from ldclient.interfaces import ( + DataSourceErrorInfo, + DataSourceErrorKind, + DataSourceState +) + +have_yaml = False +try: + import yaml + have_yaml = True +except ImportError: + pass + +have_watchdog = False +try: + import watchdog + import watchdog.events + import watchdog.observers + have_watchdog = True +except ImportError: + pass + + +def _sanitize_json_item(item): + if not ('version' in item): + item['version'] = 1 + + +class _FileDataSourceV2: + """ + Internal implementation of both Initializer and Synchronizer protocols for file-based data. + + This type is not stable, and not subject to any backwards + compatibility guarantees or semantic versioning. It is not suitable for production usage. + + Do not use it. + You have been warned. + + This component reads feature flag and segment data from local files and provides them + via the FDv2 protocol interfaces. Each instance implements both Initializer and Synchronizer + protocols: + - As an Initializer: reads files once and returns initial data + - As a Synchronizer: watches for file changes and yields updates + + The files use the same format as the v1 file data source, supporting flags, flagValues, + and segments in JSON or YAML format. + """ + + def __init__(self, paths, poll_interval=1, force_polling=False): + """ + Initialize the file data source. + + :param paths: list of file paths to load (or a single path string) + :param poll_interval: seconds between polling checks when watching files (default: 1) + :param force_polling: force polling even if watchdog is available (default: False) + """ + self._paths = paths if isinstance(paths, list) else [paths] + self._poll_interval = poll_interval + self._force_polling = force_polling + self._closed = False + self._update_queue = Queue() + self._lock = threading.Lock() + self._auto_updater = None + + @property + def name(self) -> str: + """Return the name of this data source.""" + return "FileDataV2" + + def fetch(self, ss: SelectorStore) -> BasisResult: + """ + Implementation of the Initializer.fetch method. + + Reads all configured files once and returns their contents as a Basis. + + :param ss: SelectorStore (not used, as we don't have selectors for file data) + :return: BasisResult containing the file data or an error + """ + try: + with self._lock: + if self._closed: + return _Fail("FileDataV2 source has been closed") + + # Load all files and build changeset + result = self._load_all_to_changeset() + if isinstance(result, _Fail): + return result + + change_set = result.value + + basis = Basis( + change_set=change_set, + persist=False, + environment_id=None + ) + + return _Success(basis) + + except Exception as e: + log.error('Error fetching file data: %s' % repr(e)) + traceback.print_exc() + return _Fail(f"Error fetching file data: {str(e)}") + + def sync(self, ss: SelectorStore) -> Generator[Update, None, None]: + """ + Implementation of the Synchronizer.sync method. + + Yields initial data from files, then continues to watch for file changes + and yield updates when files are modified. + + :param ss: SelectorStore (not used, as we don't have selectors for file data) + :return: Generator yielding Update objects + """ + # First yield initial data + initial_result = self.fetch(ss) + if isinstance(initial_result, _Fail): + yield Update( + state=DataSourceState.OFF, + error=DataSourceErrorInfo( + kind=DataSourceErrorKind.INVALID_DATA, + status_code=0, + time=current_time_millis(), + message=initial_result.error + ) + ) + return + + # Yield the initial successful state + yield Update( + state=DataSourceState.VALID, + change_set=initial_result.value.change_set + ) + + # Start watching for file changes + with self._lock: + if not self._closed: + self._auto_updater = self._start_auto_updater() + + # Continue yielding updates as they arrive + while not self._closed: + try: + # Wait for updates with a timeout to allow checking closed status + try: + update = self._update_queue.get(timeout=1.0) + except Empty: + continue + + if update is None: # Sentinel value for shutdown + break + + yield update + + except Exception as e: + log.error('Error in file data synchronizer: %s' % repr(e)) + traceback.print_exc() + yield Update( + state=DataSourceState.OFF, + error=DataSourceErrorInfo( + kind=DataSourceErrorKind.UNKNOWN, + status_code=0, + time=current_time_millis(), + message=f"Error in file data synchronizer: {str(e)}" + ) + ) + break + + def stop(self): + """Stop the data source and clean up resources.""" + with self._lock: + if self._closed: + return + self._closed = True + + auto_updater = self._auto_updater + self._auto_updater = None + + if auto_updater: + auto_updater.stop() + + # Signal shutdown to sync generator + self._update_queue.put(None) + + def _load_all_to_changeset(self): + """ + Load all files and build a changeset. + + :return: _Result containing ChangeSet or error string + """ + flags_dict = {} + segments_dict = {} + + for path in self._paths: + try: + self._load_file(path, flags_dict, segments_dict) + except Exception as e: + log.error('Unable to load flag data from "%s": %s' % (path, repr(e))) + traceback.print_exc() + return _Fail(f'Unable to load flag data from "{path}": {str(e)}') + + # Build a full transfer changeset + builder = ChangeSetBuilder() + builder.start(IntentCode.TRANSFER_FULL) + + # Add all flags to the changeset + for key, flag_data in flags_dict.items(): + builder.add_put( + ObjectKind.FLAG, + key, + flag_data.get('version', 1), + flag_data + ) + + # Add all segments to the changeset + for key, segment_data in segments_dict.items(): + builder.add_put( + ObjectKind.SEGMENT, + key, + segment_data.get('version', 1), + segment_data + ) + + # Use no_selector since we don't have versioning information from files + change_set = builder.finish(Selector.no_selector()) + + return _Success(change_set) + + def _load_file(self, path, flags_dict, segments_dict): + """ + Load a single file and add its contents to the provided dictionaries. + + :param path: path to the file + :param flags_dict: dictionary to add flags to + :param segments_dict: dictionary to add segments to + """ + content = None + with open(path, 'r') as f: + content = f.read() + parsed = self._parse_content(content) + + for key, flag in parsed.get('flags', {}).items(): + _sanitize_json_item(flag) + self._add_item(flags_dict, 'flags', flag) + + for key, value in parsed.get('flagValues', {}).items(): + self._add_item(flags_dict, 'flags', self._make_flag_with_value(key, value)) + + for key, segment in parsed.get('segments', {}).items(): + _sanitize_json_item(segment) + self._add_item(segments_dict, 'segments', segment) + + def _parse_content(self, content): + """ + Parse file content as JSON or YAML. + + :param content: file content string + :return: parsed dictionary + """ + if have_yaml: + return yaml.safe_load(content) # pyyaml correctly parses JSON too + return json.loads(content) + + def _add_item(self, items_dict, kind_name, item): + """ + Add an item to a dictionary, checking for duplicates. + + :param items_dict: dictionary to add to + :param kind_name: name of the kind (for error messages) + :param item: item to add + """ + key = item.get('key') + if items_dict.get(key) is None: + items_dict[key] = item + else: + raise Exception('In %s, key "%s" was used more than once' % (kind_name, key)) + + def _make_flag_with_value(self, key, value): + """ + Create a simple flag configuration from a key-value pair. + + :param key: flag key + :param value: flag value + :return: flag dictionary + """ + return {'key': key, 'version': 1, 'on': True, 'fallthrough': {'variation': 0}, 'variations': [value]} + + def _start_auto_updater(self): + """ + Start watching files for changes. + + :return: auto-updater instance + """ + resolved_paths = [] + for path in self._paths: + try: + resolved_paths.append(os.path.realpath(path)) + except Exception: + log.warning('Cannot watch for changes to data file "%s" because it is an invalid path' % path) + + if have_watchdog and not self._force_polling: + return _WatchdogAutoUpdaterV2(resolved_paths, self._on_file_change) + else: + return _PollingAutoUpdaterV2(resolved_paths, self._on_file_change, self._poll_interval) + + def _on_file_change(self): + """ + Callback invoked when files change. + + Reloads all files and queues an update. + """ + with self._lock: + if self._closed: + return + + try: + # Reload all files + result = self._load_all_to_changeset() + + if isinstance(result, _Fail): + # Queue an error update + error_update = Update( + state=DataSourceState.INTERRUPTED, + error=DataSourceErrorInfo( + kind=DataSourceErrorKind.INVALID_DATA, + status_code=0, + time=current_time_millis(), + message=result.error + ) + ) + self._update_queue.put(error_update) + else: + # Queue a successful update + update = Update( + state=DataSourceState.VALID, + change_set=result.value + ) + self._update_queue.put(update) + + except Exception as e: + log.error('Error processing file change: %s' % repr(e)) + traceback.print_exc() + error_update = Update( + state=DataSourceState.INTERRUPTED, + error=DataSourceErrorInfo( + kind=DataSourceErrorKind.UNKNOWN, + status_code=0, + time=current_time_millis(), + message=f"Error processing file change: {str(e)}" + ) + ) + self._update_queue.put(error_update) + + +# Watch for changes to data files using the watchdog package. This uses native OS filesystem notifications +# if available for the current platform. +class _WatchdogAutoUpdaterV2: + def __init__(self, resolved_paths, on_change_callback): + watched_files = set(resolved_paths) + + class LDWatchdogHandler(watchdog.events.FileSystemEventHandler): + def on_any_event(self, event): + if event.src_path in watched_files: + on_change_callback() + + dir_paths = set() + for path in resolved_paths: + dir_paths.add(os.path.dirname(path)) + + self._observer = watchdog.observers.Observer() + handler = LDWatchdogHandler() + for path in dir_paths: + self._observer.schedule(handler, path) + self._observer.start() + + def stop(self): + self._observer.stop() + self._observer.join() + + +# Watch for changes to data files by polling their modification times. This is used if auto-update is +# on but the watchdog package is not installed. +class _PollingAutoUpdaterV2: + def __init__(self, resolved_paths, on_change_callback, interval): + self._paths = resolved_paths + self._on_change = on_change_callback + self._file_times = self._check_file_times() + self._timer = RepeatingTask("ldclient.datasource.filev2.poll", interval, interval, self._poll) + self._timer.start() + + def stop(self): + self._timer.stop() + + def _poll(self): + new_times = self._check_file_times() + changed = False + for file_path, file_time in self._file_times.items(): + if new_times.get(file_path) is not None and new_times.get(file_path) != file_time: + changed = True + break + self._file_times = new_times + if changed: + self._on_change() + + def _check_file_times(self): + ret = {} + for path in self._paths: + try: + ret[path] = os.path.getmtime(path) + except Exception: + log.warning("Failed to get modification time for %s. Setting to None", path) + ret[path] = None + return ret diff --git a/ldclient/integrations/__init__.py b/ldclient/integrations/__init__.py index c78b4023..6ec31c7c 100644 --- a/ldclient/integrations/__init__.py +++ b/ldclient/integrations/__init__.py @@ -6,7 +6,7 @@ from threading import Event from typing import Any, Callable, Dict, List, Mapping, Optional -from ldclient.config import Config +from ldclient.config import Builder, Config from ldclient.feature_store import CacheConfig from ldclient.feature_store_helpers import CachingStoreWrapper from ldclient.impl.integrations.consul.consul_feature_store import ( @@ -19,6 +19,9 @@ _DynamoDBFeatureStoreCore ) from ldclient.impl.integrations.files.file_data_source import _FileDataSource +from ldclient.impl.integrations.files.file_data_sourcev2 import ( + _FileDataSourceV2 +) from ldclient.impl.integrations.redis.redis_big_segment_store import ( _RedisBigSegmentStore ) @@ -250,3 +253,62 @@ def new_data_source(paths: List[str], auto_update: bool = False, poll_interval: :return: an object (actually a lambda) to be stored in the ``update_processor_class`` configuration property """ return lambda config, store, ready: _FileDataSource(store, config.data_source_update_sink, ready, paths, auto_update, poll_interval, force_polling) + + @staticmethod + def new_data_source_v2(paths: List[str], poll_interval: float = 1, force_polling: bool = False) -> Builder[Any]: + """Provides a way to use local files as a source of feature flag state using the FDv2 protocol. + + This type is not stable, and not subject to any backwards + compatibility guarantees or semantic versioning. It is not suitable for production usage. + + Do not use it. + You have been warned. + + This returns a builder that can be used with the FDv2 data system configuration as both an + Initializer and a Synchronizer. When used as an Initializer, it reads files once. When used + as a Synchronizer, it watches for file changes and automatically updates when files are modified. + + To use this component with the FDv2 data system, call ``new_data_source_v2`` and use the returned + builder with the custom data system configuration: + :: + + from ldclient.integrations import Files + from ldclient.impl.datasystem.config import custom + + file_source = Files.new_data_source_v2(paths=['my_flags.json']) + + # Use as initializer only + data_system = custom().initializers([file_source]).build() + config = Config(data_system=data_system) + + # Use as synchronizer only + data_system = custom().synchronizers(file_source).build() + config = Config(data_system=data_system) + + # Use as both initializer and synchronizer + data_system = custom().initializers([file_source]).synchronizers(file_source).build() + config = Config(data_system=data_system) + + This will cause the client not to connect to LaunchDarkly to get feature flags. The + client may still make network connections to send analytics events, unless you have disabled + this in your configuration with ``send_events`` or ``offline``. + + The format of the data files is the same as for the v1 file data source, described in the + SDK Reference Guide on `Reading flags from a file `_. + Note that in order to use YAML, you will need to install the ``pyyaml`` package. + + If the data source encounters any error in any file-- malformed content, a missing file, or a + duplicate key-- it will not load flags from any of the files. + + :param paths: the paths of the source files for loading flag data. These may be absolute paths + or relative to the current working directory. Files will be parsed as JSON unless the ``pyyaml`` + package is installed, in which case YAML is also allowed. + :param poll_interval: (default: 1) the minimum interval, in seconds, between checks for file + modifications when used as a Synchronizer. Only applies if the native file-watching mechanism + from ``watchdog`` is not being used. + :param force_polling: (default: false) True if the data source should implement file watching via + polling the filesystem even if a native mechanism is available. This is mainly for SDK testing. + + :return: a builder function that creates the file data source + """ + return lambda config: _FileDataSourceV2(paths, poll_interval, force_polling) diff --git a/ldclient/testing/integrations/test_file_data_sourcev2.py b/ldclient/testing/integrations/test_file_data_sourcev2.py new file mode 100644 index 00000000..e69b2b93 --- /dev/null +++ b/ldclient/testing/integrations/test_file_data_sourcev2.py @@ -0,0 +1,469 @@ +import json +import os +import tempfile +import threading +import time + +import pytest + +from ldclient.config import Config +from ldclient.impl.datasystem.protocolv2 import ( + IntentCode, + ObjectKind, + Selector +) +from ldclient.impl.util import _Fail, _Success +from ldclient.integrations import Files +from ldclient.interfaces import DataSourceState +from ldclient.testing.mock_components import MockSelectorStore + +have_yaml = False +try: + import yaml + have_yaml = True +except ImportError: + pass + + +all_properties_json = ''' +{ + "flags": { + "flag1": { + "key": "flag1", + "on": true, + "fallthrough": { + "variation": 2 + }, + "variations": [ "fall", "off", "on" ] + } + }, + "flagValues": { + "flag2": "value2" + }, + "segments": { + "seg1": { + "key": "seg1", + "include": ["user1"] + } + } +} +''' + +all_properties_yaml = ''' +--- +flags: + flag1: + key: flag1 + "on": true +flagValues: + flag2: value2 +segments: + seg1: + key: seg1 + include: ["user1"] +''' + +flag_only_json = ''' +{ + "flags": { + "flag1": { + "key": "flag1", + "on": true, + "fallthrough": { + "variation": 2 + }, + "variations": [ "fall", "off", "on" ] + } + } +} +''' + +segment_only_json = ''' +{ + "segments": { + "seg1": { + "key": "seg1", + "include": ["user1"] + } + } +} +''' + +flag_values_only_json = ''' +{ + "flagValues": { + "flag2": "value2" + } +} +''' + + +def make_temp_file(content): + """Create a temporary file with the given content.""" + f, path = tempfile.mkstemp() + os.write(f, content.encode("utf-8")) + os.close(f) + return path + + +def replace_file(path, content): + """Replace the contents of a file.""" + with open(path, 'w') as f: + f.write(content) + + +def test_creates_valid_initializer(): + """Test that FileDataSourceV2 creates a working initializer.""" + path = make_temp_file(all_properties_json) + try: + file_source = Files.new_data_source_v2(paths=[path]) + initializer = file_source(Config(sdk_key="dummy")) + + result = initializer.fetch(MockSelectorStore(Selector.no_selector())) + assert isinstance(result, _Success) + + basis = result.value + assert not basis.persist + assert basis.environment_id is None + assert basis.change_set.intent_code == IntentCode.TRANSFER_FULL + + # Should have 2 flags and 1 segment + changes = basis.change_set.changes + assert len(changes) == 3 + + flag_changes = [c for c in changes if c.kind == ObjectKind.FLAG] + segment_changes = [c for c in changes if c.kind == ObjectKind.SEGMENT] + + assert len(flag_changes) == 2 + assert len(segment_changes) == 1 + + # Check selector is no_selector + assert basis.change_set.selector == Selector.no_selector() + finally: + os.remove(path) + + +def test_initializer_handles_missing_file(): + """Test that initializer returns error for missing file.""" + file_source = Files.new_data_source_v2(paths=['no-such-file.json']) + initializer = file_source(Config(sdk_key="dummy")) + + result = initializer.fetch(MockSelectorStore(Selector.no_selector())) + assert isinstance(result, _Fail) + assert "no-such-file.json" in result.error + + +def test_initializer_handles_invalid_json(): + """Test that initializer returns error for invalid JSON.""" + path = make_temp_file('{"flagValues":{') + try: + file_source = Files.new_data_source_v2(paths=[path]) + initializer = file_source(Config(sdk_key="dummy")) + + result = initializer.fetch(MockSelectorStore(Selector.no_selector())) + assert isinstance(result, _Fail) + assert "Unable to load flag data" in result.error + finally: + os.remove(path) + + +def test_initializer_handles_duplicate_keys(): + """Test that initializer returns error when same key appears in multiple files.""" + path1 = make_temp_file(flag_only_json) + path2 = make_temp_file(flag_only_json) + try: + file_source = Files.new_data_source_v2(paths=[path1, path2]) + initializer = file_source(Config(sdk_key="dummy")) + + result = initializer.fetch(MockSelectorStore(Selector.no_selector())) + assert isinstance(result, _Fail) + assert "was used more than once" in result.error + finally: + os.remove(path1) + os.remove(path2) + + +def test_initializer_loads_multiple_files(): + """Test that initializer can load from multiple files.""" + path1 = make_temp_file(flag_only_json) + path2 = make_temp_file(segment_only_json) + try: + file_source = Files.new_data_source_v2(paths=[path1, path2]) + initializer = file_source(Config(sdk_key="dummy")) + + result = initializer.fetch(MockSelectorStore(Selector.no_selector())) + assert isinstance(result, _Success) + + changes = result.value.change_set.changes + flag_changes = [c for c in changes if c.kind == ObjectKind.FLAG] + segment_changes = [c for c in changes if c.kind == ObjectKind.SEGMENT] + + assert len(flag_changes) == 1 + assert len(segment_changes) == 1 + finally: + os.remove(path1) + os.remove(path2) + + +def test_initializer_loads_yaml(): + """Test that initializer can parse YAML files.""" + if not have_yaml: + pytest.skip("skipping YAML test because pyyaml isn't available") + + path = make_temp_file(all_properties_yaml) + try: + file_source = Files.new_data_source_v2(paths=[path]) + initializer = file_source(Config(sdk_key="dummy")) + + result = initializer.fetch(MockSelectorStore(Selector.no_selector())) + assert isinstance(result, _Success) + + changes = result.value.change_set.changes + assert len(changes) == 3 # 2 flags + 1 segment + finally: + os.remove(path) + + +def test_initializer_handles_flag_values(): + """Test that initializer properly converts flagValues to flags.""" + path = make_temp_file(flag_values_only_json) + try: + file_source = Files.new_data_source_v2(paths=[path]) + initializer = file_source(Config(sdk_key="dummy")) + + result = initializer.fetch(MockSelectorStore(Selector.no_selector())) + assert isinstance(result, _Success) + + changes = result.value.change_set.changes + flag_changes = [c for c in changes if c.kind == ObjectKind.FLAG] + assert len(flag_changes) == 1 + + # Check the flag was created with the expected structure + flag_change = flag_changes[0] + assert flag_change.key == "flag2" + assert flag_change.object['key'] == "flag2" + assert flag_change.object['on'] is True + assert flag_change.object['variations'] == ["value2"] + finally: + os.remove(path) + + +def test_creates_valid_synchronizer(): + """Test that FileDataSourceV2 creates a working synchronizer.""" + path = make_temp_file(all_properties_json) + try: + file_source = Files.new_data_source_v2(paths=[path], force_polling=True, poll_interval=0.1) + synchronizer = file_source(Config(sdk_key="dummy")) + + updates = [] + update_count = 0 + + def collect_updates(): + nonlocal update_count + for update in synchronizer.sync(MockSelectorStore(Selector.no_selector())): + updates.append(update) + update_count += 1 + + if update_count == 1: + # Should get initial state + assert update.state == DataSourceState.VALID + assert update.change_set is not None + assert update.change_set.intent_code == IntentCode.TRANSFER_FULL + assert len(update.change_set.changes) == 3 + synchronizer.stop() + break + + # Start the synchronizer in a thread with timeout to prevent hanging + sync_thread = threading.Thread(target=collect_updates) + sync_thread.start() + + # Wait for the thread to complete with timeout + sync_thread.join(timeout=5) + + # Ensure thread completed successfully + if sync_thread.is_alive(): + synchronizer.stop() + sync_thread.join() + pytest.fail("Synchronizer test timed out after 5 seconds") + + assert len(updates) == 1 + finally: + synchronizer.stop() + os.remove(path) + + +def test_synchronizer_detects_file_changes(): + """Test that synchronizer detects and reports file changes.""" + path = make_temp_file(flag_only_json) + try: + file_source = Files.new_data_source_v2(paths=[path], force_polling=True, poll_interval=0.1) + synchronizer = file_source(Config(sdk_key="dummy")) + + updates = [] + update_event = threading.Event() + + def collect_updates(): + for update in synchronizer.sync(MockSelectorStore(Selector.no_selector())): + updates.append(update) + update_event.set() + + if len(updates) >= 2: + break + + # Start the synchronizer + sync_thread = threading.Thread(target=collect_updates) + sync_thread.start() + + # Wait for initial update + assert update_event.wait(timeout=2), "Did not receive initial update" + assert len(updates) == 1 + assert updates[0].state == DataSourceState.VALID + initial_changes = [c for c in updates[0].change_set.changes if c.kind == ObjectKind.FLAG] + assert len(initial_changes) == 1 + + # Modify the file + update_event.clear() + time.sleep(0.2) # Ensure filesystem timestamp changes + replace_file(path, segment_only_json) + + # Wait for the change to be detected + assert update_event.wait(timeout=2), "Did not receive update after file change" + assert len(updates) == 2 + assert updates[1].state == DataSourceState.VALID + segment_changes = [c for c in updates[1].change_set.changes if c.kind == ObjectKind.SEGMENT] + assert len(segment_changes) == 1 + + synchronizer.stop() + sync_thread.join(timeout=2) + finally: + synchronizer.stop() + os.remove(path) + + +def test_synchronizer_reports_error_on_invalid_file_update(): + """Test that synchronizer reports error when file becomes invalid.""" + path = make_temp_file(flag_only_json) + try: + file_source = Files.new_data_source_v2(paths=[path], force_polling=True, poll_interval=0.1) + synchronizer = file_source(Config(sdk_key="dummy")) + + updates = [] + update_event = threading.Event() + + def collect_updates(): + for update in synchronizer.sync(MockSelectorStore(Selector.no_selector())): + updates.append(update) + update_event.set() + + if len(updates) >= 2: + break + + # Start the synchronizer + sync_thread = threading.Thread(target=collect_updates) + sync_thread.start() + + # Wait for initial update + assert update_event.wait(timeout=2), "Did not receive initial update" + assert len(updates) == 1 + assert updates[0].state == DataSourceState.VALID + + # Make the file invalid + update_event.clear() + time.sleep(0.2) # Ensure filesystem timestamp changes + replace_file(path, '{"invalid json') + + # Wait for the error to be detected + assert update_event.wait(timeout=2), "Did not receive update after file became invalid" + assert len(updates) == 2 + assert updates[1].state == DataSourceState.INTERRUPTED + assert updates[1].error is not None + + synchronizer.stop() + sync_thread.join(timeout=2) + finally: + synchronizer.stop() + os.remove(path) + + +def test_synchronizer_can_be_stopped(): + """Test that synchronizer stops cleanly.""" + path = make_temp_file(all_properties_json) + try: + file_source = Files.new_data_source_v2(paths=[path]) + synchronizer = file_source(Config(sdk_key="dummy")) + + updates = [] + + def collect_updates(): + for update in synchronizer.sync(MockSelectorStore(Selector.no_selector())): + updates.append(update) + + # Start the synchronizer + sync_thread = threading.Thread(target=collect_updates) + sync_thread.start() + + # Give it a moment to process initial data + time.sleep(0.2) + + # Stop it + synchronizer.stop() + + # Thread should complete + sync_thread.join(timeout=2) + assert not sync_thread.is_alive() + + # Should have received at least the initial update + assert len(updates) >= 1 + assert updates[0].state == DataSourceState.VALID + finally: + os.remove(path) + + +def test_fetch_after_stop_returns_error(): + """Test that fetch returns error after synchronizer is stopped.""" + path = make_temp_file(all_properties_json) + try: + file_source = Files.new_data_source_v2(paths=[path]) + initializer = file_source(Config(sdk_key="dummy")) + + # First fetch should work + result = initializer.fetch(MockSelectorStore(Selector.no_selector())) + assert isinstance(result, _Success) + + # Stop the source + initializer.stop() + + # Second fetch should fail + result = initializer.fetch(MockSelectorStore(Selector.no_selector())) + assert isinstance(result, _Fail) + assert "closed" in result.error + finally: + os.remove(path) + + +def test_source_name_property(): + """Test that the data source has the correct name.""" + path = make_temp_file(all_properties_json) + try: + file_source = Files.new_data_source_v2(paths=[path]) + source = file_source(Config(sdk_key="dummy")) + + assert source.name == "FileDataV2" + finally: + source.stop() + os.remove(path) + + +def test_accepts_single_path_string(): + """Test that paths parameter can be a single string.""" + path = make_temp_file(flag_only_json) + try: + # Pass a single string instead of a list + file_source = Files.new_data_source_v2(paths=path) + initializer = file_source(Config(sdk_key="dummy")) + + result = initializer.fetch(MockSelectorStore(Selector.no_selector())) + assert isinstance(result, _Success) + assert len(result.value.change_set.changes) == 1 + finally: + os.remove(path) From 3c9736a75acb7d9bba488df23ee129f54257796a Mon Sep 17 00:00:00 2001 From: "Matthew M. Keeler" Date: Tue, 18 Nov 2025 13:54:53 -0500 Subject: [PATCH 11/13] chore: Flush in memory store on persistent store recovery (#372) --- ldclient/client.py | 13 +- ldclient/impl/datasourcev2/polling.py | 2 +- ldclient/impl/datasystem/fdv2.py | 23 +- ldclient/impl/datasystem/protocolv2.py | 2 +- ldclient/impl/datasystem/store.py | 9 +- .../impl/datasystem/test_fdv2_persistence.py | 242 ++++++++++++++++++ 6 files changed, 278 insertions(+), 13 deletions(-) diff --git a/ldclient/client.py b/ldclient/client.py index 3cd3b9be..7022f137 100644 --- a/ldclient/client.py +++ b/ldclient/client.py @@ -273,14 +273,13 @@ def __start_up(self, start_wait: float): self._data_system.data_source_status_provider ) self.__flag_tracker = self._data_system.flag_tracker - self._store: ReadOnlyStore = self._data_system.store big_segment_store_manager = BigSegmentStoreManager(self._config.big_segments) self.__big_segment_store_manager = big_segment_store_manager self._evaluator = Evaluator( - lambda key: _get_store_item(self._store, FEATURES, key), - lambda key: _get_store_item(self._store, SEGMENTS, key), + lambda key: _get_store_item(self._data_system.store, FEATURES, key), + lambda key: _get_store_item(self._data_system.store, SEGMENTS, key), lambda key: big_segment_store_manager.get_user_membership(key), log, ) @@ -571,7 +570,7 @@ def _evaluate_internal(self, key: str, context: Context, default: Any, event_fac return EvaluationDetail(default, None, error_reason('CLIENT_NOT_READY')), None if not self.is_initialized(): - if self._store.initialized: + if self._data_system.store.initialized: log.warning("Feature Flag evaluation attempted before client has initialized - using last known values from feature store for feature key: " + key) else: log.warning("Feature Flag evaluation attempted before client has initialized! Feature store unavailable - returning default: " + str(default) + " for feature key: " + key) @@ -584,7 +583,7 @@ def _evaluate_internal(self, key: str, context: Context, default: Any, event_fac return EvaluationDetail(default, None, error_reason('USER_NOT_SPECIFIED')), None try: - flag = _get_store_item(self._store, FEATURES, key) + flag = _get_store_item(self._data_system.store, FEATURES, key) except Exception as e: log.error("Unexpected error while retrieving feature flag \"%s\": %s" % (key, repr(e))) log.debug(traceback.format_exc()) @@ -642,7 +641,7 @@ def all_flags_state(self, context: Context, **kwargs) -> FeatureFlagsState: return FeatureFlagsState(False) if not self.is_initialized(): - if self._store.initialized: + if self._data_system.store.initialized: log.warning("all_flags_state() called before client has finished initializing! Using last known values from feature store") else: log.warning("all_flags_state() called before client has finished initializing! Feature store unavailable - returning empty state") @@ -657,7 +656,7 @@ def all_flags_state(self, context: Context, **kwargs) -> FeatureFlagsState: with_reasons = kwargs.get('with_reasons', False) details_only_if_tracked = kwargs.get('details_only_for_tracked_flags', False) try: - flags_map = self._store.all(FEATURES, lambda x: x) + flags_map = self._data_system.store.all(FEATURES, lambda x: x) if flags_map is None: raise ValueError("feature store error") except Exception as e: diff --git a/ldclient/impl/datasourcev2/polling.py b/ldclient/impl/datasourcev2/polling.py index e5415039..4df2c32e 100644 --- a/ldclient/impl/datasourcev2/polling.py +++ b/ldclient/impl/datasourcev2/polling.py @@ -257,7 +257,7 @@ def fetch(self, selector: Optional[Selector]) -> PollingResult: if self._config.payload_filter_key is not None: query_params["filter"] = self._config.payload_filter_key - if selector is not None: + if selector is not None and selector.is_defined(): query_params["selector"] = selector.state uri = self._poll_uri diff --git a/ldclient/impl/datasystem/fdv2.py b/ldclient/impl/datasystem/fdv2.py index 41df248b..91b5494e 100644 --- a/ldclient/impl/datasystem/fdv2.py +++ b/ldclient/impl/datasystem/fdv2.py @@ -89,7 +89,7 @@ def __update_availability(self, available: bool): if available: log.warning("Persistent store is available again") - status = DataStoreStatus(available, False) + status = DataStoreStatus(available, True) self.__store_update_sink.update_status(status) if available: @@ -185,16 +185,18 @@ def __init__( self._change_set_listeners = Listeners() self._data_store_listeners = Listeners() + self._data_store_listeners.add(self._persistent_store_outage_recovery) + # Create the store self._store = Store(self._flag_change_listeners, self._change_set_listeners) # Status providers self._data_source_status_provider = DataSourceStatusProviderImpl(Listeners()) - self._data_store_status_provider = DataStoreStatusProviderImpl(None, Listeners()) + self._data_store_status_provider = DataStoreStatusProviderImpl(None, self._data_store_listeners) # Configure persistent store if provided if self._data_system_config.data_store is not None: - self._data_store_status_provider = DataStoreStatusProviderImpl(self._data_system_config.data_store, Listeners()) + self._data_store_status_provider = DataStoreStatusProviderImpl(self._data_system_config.data_store, self._data_store_listeners) writable = self._data_system_config.data_store_mode == DataStoreMode.READ_WRITE wrapper = FeatureStoreClientWrapper(self._data_system_config.data_store, self._data_store_status_provider) self._store.with_persistence( @@ -509,6 +511,21 @@ def _recovery_condition(self, status: DataSourceStatus) -> bool: return interrupted_at_runtime or healthy_for_too_long or cannot_initialize + def _persistent_store_outage_recovery(self, data_store_status: DataStoreStatus): + """ + Monitor the data store status. If the store comes online and + potentially has stale data, we should write our known state to it. + """ + if not data_store_status.available: + return + + if not data_store_status.stale: + return + + err = self._store.commit() + if err is not None: + log.error("Failed to reinitialize data store", exc_info=err) + @property def store(self) -> ReadOnlyStore: """Get the underlying store for flag evaluation.""" diff --git a/ldclient/impl/datasystem/protocolv2.py b/ldclient/impl/datasystem/protocolv2.py index e61f019e..c26ad746 100644 --- a/ldclient/impl/datasystem/protocolv2.py +++ b/ldclient/impl/datasystem/protocolv2.py @@ -505,7 +505,7 @@ def name(self) -> str: """Returns the name of the initializer.""" raise NotImplementedError - def sync(self, ss: "SelectorStore") -> "Generator[Update, None, None]": + def sync(self, ss: "SelectorStore") -> Generator["Update", None, None]: """ sync should begin the synchronization process for the data source, yielding Update objects until the connection is closed or an unrecoverable error diff --git a/ldclient/impl/datasystem/store.py b/ldclient/impl/datasystem/store.py index 20aea90e..49f0a70a 100644 --- a/ldclient/impl/datasystem/store.py +++ b/ldclient/impl/datasystem/store.py @@ -20,6 +20,7 @@ ) from ldclient.impl.dependency_tracker import DependencyTracker, KindAndKey from ldclient.impl.listeners import Listeners +from ldclient.impl.model.entity import ModelEntity from ldclient.impl.rwlock import ReadWriteLock from ldclient.impl.util import log from ldclient.interfaces import ( @@ -451,13 +452,19 @@ def commit(self) -> Optional[Exception]: Returns: Exception if commit failed, None otherwise """ + def __mapping_from_kind(kind: VersionedDataKind) -> Callable[[Dict[str, ModelEntity]], Dict[str, Dict[str, Any]]]: + def __mapping(data: Dict[str, ModelEntity]) -> Dict[str, Dict[str, Any]]: + return {k: kind.encode(v) for k, v in data.items()} + + return __mapping + with self._lock: if self._should_persist(): try: # Get all data from memory store and write to persistent store all_data = {} for kind in [FEATURES, SEGMENTS]: - all_data[kind] = self._memory_store.all(kind, lambda x: x) + all_data[kind] = self._memory_store.all(kind, __mapping_from_kind(kind)) self._persistent_store.init(all_data) # type: ignore except Exception as e: return e diff --git a/ldclient/testing/impl/datasystem/test_fdv2_persistence.py b/ldclient/testing/impl/datasystem/test_fdv2_persistence.py index 999f4d07..7f77da17 100644 --- a/ldclient/testing/impl/datasystem/test_fdv2_persistence.py +++ b/ldclient/testing/impl/datasystem/test_fdv2_persistence.py @@ -537,3 +537,245 @@ def test_no_persistent_store_status_provider_without_store(): assert set_on_ready.wait(1), "Data system did not become ready in time" fdv2.stop() + + +def test_persistent_store_outage_recovery_flushes_on_recovery(): + """Test that in-memory store is flushed to persistent store when it recovers from outage""" + from ldclient.interfaces import DataStoreStatus + + persistent_store = StubFeatureStore() + + # Create synchronizer with initial data + td_synchronizer = TestDataV2.data_source() + td_synchronizer.update(td_synchronizer.flag("feature-flag").on(True)) + + data_system_config = DataSystemConfig( + data_store_mode=DataStoreMode.READ_WRITE, + data_store=persistent_store, + initializers=None, + primary_synchronizer=td_synchronizer.build_synchronizer, + ) + + set_on_ready = Event() + fdv2 = FDv2(Config(sdk_key="dummy"), data_system_config) + fdv2.start(set_on_ready) + + assert set_on_ready.wait(1), "Data system did not become ready in time" + + # Verify initial data is in the persistent store + snapshot = persistent_store.get_data_snapshot() + assert "feature-flag" in snapshot[FEATURES] + assert snapshot[FEATURES]["feature-flag"]["on"] is True + + # Reset tracking to isolate recovery behavior + persistent_store.reset_operation_tracking() + + event = Event() + fdv2.flag_tracker.add_listener(lambda _flag_change: event.set()) + # Simulate a new flag being added while store is "offline" + # (In reality, the store is still online, but we're testing the recovery mechanism) + td_synchronizer.update(td_synchronizer.flag("new-flag").on(False)) + + # Block until the flag has propagated through the data store + assert event.wait(1) + + # Now simulate the persistent store coming back online with stale data + # by triggering the recovery callback directly + fdv2._persistent_store_outage_recovery(DataStoreStatus(available=True, stale=True)) + + # Verify that init was called on the persistent store (flushing in-memory data) + assert persistent_store.init_called_count > 0, "Store should have been reinitialized" + + # Verify both flags are now in the persistent store + snapshot = persistent_store.get_data_snapshot() + assert "feature-flag" in snapshot[FEATURES] + assert "new-flag" in snapshot[FEATURES] + + fdv2.stop() + + +def test_persistent_store_outage_recovery_no_flush_when_not_stale(): + """Test that recovery does NOT flush when store comes back online without stale data""" + from ldclient.interfaces import DataStoreStatus + + persistent_store = StubFeatureStore() + + td_synchronizer = TestDataV2.data_source() + td_synchronizer.update(td_synchronizer.flag("feature-flag").on(True)) + + data_system_config = DataSystemConfig( + data_store_mode=DataStoreMode.READ_WRITE, + data_store=persistent_store, + initializers=None, + primary_synchronizer=td_synchronizer.build_synchronizer, + ) + + set_on_ready = Event() + fdv2 = FDv2(Config(sdk_key="dummy"), data_system_config) + fdv2.start(set_on_ready) + + assert set_on_ready.wait(1), "Data system did not become ready in time" + + # Reset tracking + persistent_store.reset_operation_tracking() + + # Simulate store coming back online but NOT stale (data is fresh) + fdv2._persistent_store_outage_recovery(DataStoreStatus(available=True, stale=False)) + + # Verify that init was NOT called (no flush needed) + assert persistent_store.init_called_count == 0, "Store should not be reinitialized when not stale" + + fdv2.stop() + + +def test_persistent_store_outage_recovery_no_flush_when_unavailable(): + """Test that recovery does NOT flush when store is unavailable""" + from ldclient.interfaces import DataStoreStatus + + persistent_store = StubFeatureStore() + + td_synchronizer = TestDataV2.data_source() + td_synchronizer.update(td_synchronizer.flag("feature-flag").on(True)) + + data_system_config = DataSystemConfig( + data_store_mode=DataStoreMode.READ_WRITE, + data_store=persistent_store, + initializers=None, + primary_synchronizer=td_synchronizer.build_synchronizer, + ) + + set_on_ready = Event() + fdv2 = FDv2(Config(sdk_key="dummy"), data_system_config) + fdv2.start(set_on_ready) + + assert set_on_ready.wait(1), "Data system did not become ready in time" + + # Reset tracking + persistent_store.reset_operation_tracking() + + # Simulate store being unavailable (even if marked as stale) + fdv2._persistent_store_outage_recovery(DataStoreStatus(available=False, stale=True)) + + # Verify that init was NOT called (store is not available) + assert persistent_store.init_called_count == 0, "Store should not be reinitialized when unavailable" + + fdv2.stop() + + +def test_persistent_store_commit_encodes_data_correctly(): + """Test that Store.commit() properly encodes data before writing to persistent store""" + from ldclient.impl.datasystem.protocolv2 import ( + Change, + ChangeSet, + ChangeType, + IntentCode, + ObjectKind + ) + from ldclient.impl.datasystem.store import Store + from ldclient.impl.listeners import Listeners + + persistent_store = StubFeatureStore() + store = Store(Listeners(), Listeners()) + store.with_persistence(persistent_store, True, None) + + # Create a flag with raw data + flag_data = { + "key": "test-flag", + "version": 1, + "on": True, + "variations": [True, False], + "fallthrough": {"variation": 0}, + } + + # Apply a changeset to add the flag to the in-memory store + changeset = ChangeSet( + intent_code=IntentCode.TRANSFER_FULL, + changes=[ + Change( + action=ChangeType.PUT, + kind=ObjectKind.FLAG, + key="test-flag", + version=1, + object=flag_data, + ) + ], + selector=None, + ) + store.apply(changeset, True) + + # Reset tracking + persistent_store.reset_operation_tracking() + + # Now commit the in-memory store to the persistent store + err = store.commit() + assert err is None, "Commit should succeed" + + # Verify that init was called with properly encoded data + assert persistent_store.init_called_count == 1, "Init should be called once" + + # Verify the data in the persistent store is properly encoded + snapshot = persistent_store.get_data_snapshot() + assert "test-flag" in snapshot[FEATURES] + + # The data should be in the encoded format (as a dict with all required fields) + flag_in_store = snapshot[FEATURES]["test-flag"] + assert flag_in_store["key"] == "test-flag" + assert flag_in_store["version"] == 1 + assert flag_in_store["on"] is True + + +def test_persistent_store_commit_with_no_persistent_store(): + """Test that Store.commit() safely handles the case where there's no persistent store""" + from ldclient.impl.datasystem.store import Store + from ldclient.impl.listeners import Listeners + + # Create store without persistent store + store = Store(Listeners(), Listeners()) + + # Commit should succeed but do nothing + err = store.commit() + assert err is None, "Commit should succeed even without persistent store" + + +def test_persistent_store_commit_handles_errors(): + """Test that Store.commit() handles errors from persistent store gracefully""" + from ldclient.impl.datasystem.protocolv2 import ( + Change, + ChangeSet, + ChangeType, + IntentCode, + ObjectKind + ) + from ldclient.impl.datasystem.store import Store + from ldclient.impl.listeners import Listeners + + class FailingFeatureStore(StubFeatureStore): + """A feature store that always fails on init""" + def init(self, all_data): + raise RuntimeError("Simulated persistent store failure") + + persistent_store = FailingFeatureStore() + store = Store(Listeners(), Listeners()) + store.with_persistence(persistent_store, True, None) + + # Add some data to the in-memory store + changeset = ChangeSet( + intent_code=IntentCode.TRANSFER_FULL, + changes=[ + Change( + action=ChangeType.PUT, + kind=ObjectKind.FLAG, + key="test-flag", + version=1, + object={"key": "test-flag", "version": 1, "on": True}, + ) + ], + selector=None, + ) + store.apply(changeset, True) + + # Commit should return the error without raising + err = store.commit() + assert err is not None, "Commit should return error from persistent store" + assert isinstance(err, RuntimeError) + assert str(err) == "Simulated persistent store failure" From 750d2733f28ef8a456c5860dbfed7dc3b3aa0a6a Mon Sep 17 00:00:00 2001 From: "Matthew M. Keeler" Date: Wed, 19 Nov 2025 16:35:23 -0500 Subject: [PATCH 12/13] chore: Separate status check from synchronizer functionality (#373) In the previous setup, we would only check the fallback or recovery conditions once the synchronizer returned an update. If the synchronizer was stuck, or nothing was changing in the environment, we would never check the conditions. This configuration also exposed an interesting behavior. If the synchronizer cannot connect, it will emit error updates. Each time we receive an error, we check if we have failed to initialize for the last 10 seconds. If so, we re-create the primary synchronizer. When it continues to fail, the first update will trigger the condition check. And since it has still failed for 10 seconds, it will immediately error out. With this change, we can be assured a synchronizer is given at least 10 seconds to try before the condition is evaluated. --- .github/workflows/ci.yml | 4 ++ ldclient/impl/datasourcev2/status.py | 2 +- ldclient/impl/datasourcev2/streaming.py | 7 --- ldclient/impl/datasystem/fdv2.py | 54 +++++++++++++++---- .../impl/datasystem/test_fdv2_datasystem.py | 19 +++++-- .../integrations/test_file_data_sourcev2.py | 6 +++ ldclient/testing/test_file_data_source.py | 6 +++ 7 files changed, 76 insertions(+), 22 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3dff9219..eb7a2021 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -40,6 +40,8 @@ jobs: - name: Run tests run: make test-all + env: + LD_SKIP_FLAKY_TESTS: true - name: Verify typehints run: make lint @@ -92,3 +94,5 @@ jobs: - name: Run tests run: make test-all + env: + LD_SKIP_FLAKY_TESTS: true diff --git a/ldclient/impl/datasourcev2/status.py b/ldclient/impl/datasourcev2/status.py index 3f417f34..05e12e56 100644 --- a/ldclient/impl/datasourcev2/status.py +++ b/ldclient/impl/datasourcev2/status.py @@ -19,7 +19,7 @@ class DataSourceStatusProviderImpl(DataSourceStatusProvider): def __init__(self, listeners: Listeners): self.__listeners = listeners - self.__status = DataSourceStatus(DataSourceState.INITIALIZING, 0, None) + self.__status = DataSourceStatus(DataSourceState.INITIALIZING, time.time(), None) self.__lock = ReadWriteLock() @property diff --git a/ldclient/impl/datasourcev2/streaming.py b/ldclient/impl/datasourcev2/streaming.py index eab7fa8d..c287c171 100644 --- a/ldclient/impl/datasourcev2/streaming.py +++ b/ldclient/impl/datasourcev2/streaming.py @@ -405,13 +405,6 @@ def _handle_error(self, error: Exception, envid: Optional[str]) -> Tuple[Optiona return (update, True) - # magic methods for "with" statement (used in testing) - def __enter__(self): - return self - - def __exit__(self, type, value, traceback): - self.stop() - class StreamingDataSourceBuilder: # disable: pylint: disable=too-few-public-methods """ diff --git a/ldclient/impl/datasystem/fdv2.py b/ldclient/impl/datasystem/fdv2.py index 91b5494e..64d26c77 100644 --- a/ldclient/impl/datasystem/fdv2.py +++ b/ldclient/impl/datasystem/fdv2.py @@ -1,5 +1,6 @@ import logging import time +from queue import Empty, Queue from threading import Event, Thread from typing import Any, Callable, Dict, List, Mapping, Optional @@ -367,11 +368,12 @@ def synchronizer_loop(self: 'FDv2'): else: log.info("Fallback condition met") - if self._secondary_synchronizer_builder is None: - continue if self._stop_event.is_set(): break + if self._secondary_synchronizer_builder is None: + continue + self._lock.lock() secondary_sync = self._secondary_synchronizer_builder(self._config) if isinstance(secondary_sync, DiagnosticSource) and self._diagnostic_accumulator is not None: @@ -433,8 +435,45 @@ def _consume_synchronizer_results( :return: Tuple of (should_remove_sync, fallback_to_fdv1) """ + action_queue: Queue = Queue() + timer = RepeatingTask( + label="FDv2-sync-cond-timer", + interval=10, + initial_delay=10, + callable=lambda: action_queue.put("check") + ) + + def reader(self: 'FDv2'): + try: + for update in synchronizer.sync(self._store): + action_queue.put(update) + finally: + action_queue.put("quit") + + sync_reader = Thread( + target=reader, + name="FDv2-sync-reader", + args=(self,), + daemon=True + ) + try: - for update in synchronizer.sync(self._store): + timer.start() + sync_reader.start() + + while True: + update = action_queue.get(True) + if isinstance(update, str): + if update == "quit": + break + + if update == "check": + # Check condition periodically + current_status = self._data_source_status_provider.status + if condition_func(current_status): + return False, False + continue + log.info("Synchronizer %s update: %s", synchronizer.name, update.state) if self._stop_event.is_set(): return False, False @@ -457,17 +496,14 @@ def _consume_synchronizer_results( # Check for OFF state indicating permanent failure if update.state == DataSourceState.OFF: return True, False - - # Check condition periodically - current_status = self._data_source_status_provider.status - if condition_func(current_status): - return False, False - except Exception as e: log.error("Error consuming synchronizer results: %s", e) return True, False finally: synchronizer.stop() + timer.stop() + + sync_reader.join(0.5) return True, False diff --git a/ldclient/testing/impl/datasystem/test_fdv2_datasystem.py b/ldclient/testing/impl/datasystem/test_fdv2_datasystem.py index c1bb6895..dd9a3e97 100644 --- a/ldclient/testing/impl/datasystem/test_fdv2_datasystem.py +++ b/ldclient/testing/impl/datasystem/test_fdv2_datasystem.py @@ -18,7 +18,11 @@ def test_two_phase_init(): td_initializer.update(td_initializer.flag("feature-flag").on(True)) td_synchronizer = TestDataV2.data_source() - td_synchronizer.update(td_synchronizer.flag("feature-flag").on(True)) + # Set this to true, and then to false to ensure the version number exceeded + # the initializer version number. Otherwise, they start as the same version + # and the latest value is ignored. + td_synchronizer.update(td_initializer.flag("feature-flag").on(True)) + td_synchronizer.update(td_synchronizer.flag("feature-flag").on(False)) data_system_config = DataSystemConfig( initializers=[td_initializer.build_initializer], primary_synchronizer=td_synchronizer.build_synchronizer, @@ -27,7 +31,8 @@ def test_two_phase_init(): set_on_ready = Event() fdv2 = FDv2(Config(sdk_key="dummy"), data_system_config) - changed = Event() + initialized = Event() + modified = Event() changes: List[FlagChange] = [] count = 0 @@ -37,18 +42,22 @@ def listener(flag_change: FlagChange): changes.append(flag_change) if count == 2: - changed.set() + initialized.set() + if count == 3: + modified.set() fdv2.flag_tracker.add_listener(listener) fdv2.start(set_on_ready) assert set_on_ready.wait(1), "Data system did not become ready in time" + assert initialized.wait(1), "Flag change listener was not called in time" td_synchronizer.update(td_synchronizer.flag("feature-flag").on(False)) - assert changed.wait(1), "Flag change listener was not called in time" - assert len(changes) == 2 + assert modified.wait(1), "Flag change listener was not called in time" + assert len(changes) == 3 assert changes[0].key == "feature-flag" assert changes[1].key == "feature-flag" + assert changes[2].key == "feature-flag" def test_can_stop_fdv2(): diff --git a/ldclient/testing/integrations/test_file_data_sourcev2.py b/ldclient/testing/integrations/test_file_data_sourcev2.py index e69b2b93..35bd8381 100644 --- a/ldclient/testing/integrations/test_file_data_sourcev2.py +++ b/ldclient/testing/integrations/test_file_data_sourcev2.py @@ -17,6 +17,12 @@ from ldclient.interfaces import DataSourceState from ldclient.testing.mock_components import MockSelectorStore +# Skip all tests in this module in CI due to flakiness +pytestmark = pytest.mark.skipif( + os.getenv('LD_SKIP_FLAKY_TESTS', '').lower() in ('true', '1', 'yes'), + reason="Skipping flaky test" +) + have_yaml = False try: import yaml diff --git a/ldclient/testing/test_file_data_source.py b/ldclient/testing/test_file_data_source.py index 62646d9e..b8e3fb0b 100644 --- a/ldclient/testing/test_file_data_source.py +++ b/ldclient/testing/test_file_data_source.py @@ -21,6 +21,12 @@ from ldclient.testing.test_util import SpyListener from ldclient.versioned_data_kind import FEATURES, SEGMENTS +# Skip all tests in this module in CI due to flakiness +pytestmark = pytest.mark.skipif( + os.getenv('LD_SKIP_FLAKY_TESTS', '').lower() in ('true', '1', 'yes'), + reason="Skipping flaky test" +) + have_yaml = False try: import yaml From 23dae3d27f1d121c7e38f9d8177f99e96c113770 Mon Sep 17 00:00:00 2001 From: "Matthew M. Keeler" Date: Wed, 19 Nov 2025 17:00:05 -0500 Subject: [PATCH 13/13] chore: Reorganize import structure to align with compatibility expectations (#374) --- contract-tests/client_entity.py | 10 +- ldclient/config.py | 3 +- .../datasystem/config.py => datasystem.py} | 8 +- ldclient/impl/datasourcev2/__init__.py | 8 +- ldclient/impl/datasourcev2/polling.py | 32 +- ldclient/impl/datasourcev2/status.py | 109 ---- ldclient/impl/datasourcev2/streaming.py | 23 +- ldclient/impl/datasystem/__init__.py | 106 +--- ldclient/impl/datasystem/fdv1.py | 8 +- ldclient/impl/datasystem/fdv2.py | 111 +++- ldclient/impl/datasystem/protocolv2.py | 464 +-------------- ldclient/impl/datasystem/store.py | 16 +- .../integrations/files/file_data_sourcev2.py | 118 ++-- .../test_datav2/test_data_sourcev2.py | 56 +- ldclient/interfaces.py | 559 +++++++++++++++++- .../datasourcev2/test_polling_initializer.py | 2 +- .../test_polling_payload_parsing.py | 2 +- .../datasourcev2/test_polling_synchronizer.py | 24 +- .../test_streaming_synchronizer.py | 13 +- .../testing/impl/datasystem/test_config.py | 2 +- .../impl/datasystem/test_fdv2_datasystem.py | 17 +- .../impl/datasystem/test_fdv2_persistence.py | 18 +- .../integrations/test_file_data_sourcev2.py | 8 +- .../integrations/test_test_data_sourcev2.py | 8 +- ldclient/testing/mock_components.py | 7 +- 25 files changed, 859 insertions(+), 873 deletions(-) rename ldclient/{impl/datasystem/config.py => datasystem.py} (98%) delete mode 100644 ldclient/impl/datasourcev2/status.py diff --git a/contract-tests/client_entity.py b/contract-tests/client_entity.py index 6b627851..f68f7488 100644 --- a/contract-tests/client_entity.py +++ b/contract-tests/client_entity.py @@ -15,12 +15,13 @@ Stage ) from ldclient.config import BigSegmentsConfig -from ldclient.impl.datasourcev2.polling import PollingDataSourceBuilder -from ldclient.impl.datasystem.config import ( +from ldclient.datasystem import ( custom, + fdv1_fallback_ds_builder, polling_ds_builder, streaming_ds_builder ) +from ldclient.impl.datasourcev2.polling import PollingDataSourceBuilder class ClientEntity: @@ -59,6 +60,7 @@ def __init__(self, tag, config): primary_builder = None secondary_builder = None + fallback_builder = None if primary is not None: streaming = primary.get('streaming') @@ -74,6 +76,7 @@ def __init__(self, tag, config): opts["base_uri"] = polling["baseUri"] _set_optional_time_prop(polling, "pollIntervalMs", opts, "poll_interval") primary_builder = polling_ds_builder() + fallback_builder = fdv1_fallback_ds_builder() if secondary is not None: streaming = secondary.get('streaming') @@ -89,9 +92,12 @@ def __init__(self, tag, config): opts["base_uri"] = polling["baseUri"] _set_optional_time_prop(polling, "pollIntervalMs", opts, "poll_interval") secondary_builder = polling_ds_builder() + fallback_builder = fdv1_fallback_ds_builder() if primary_builder is not None: datasystem.synchronizers(primary_builder, secondary_builder) + if fallback_builder is not None: + datasystem.fdv1_compatible_synchronizer(fallback_builder) if datasystem_config.get("payloadFilter") is not None: opts["payload_filter_key"] = datasystem_config["payloadFilter"] diff --git a/ldclient/config.py b/ldclient/config.py index 6d690637..8e5caf76 100644 --- a/ldclient/config.py +++ b/ldclient/config.py @@ -10,7 +10,6 @@ from ldclient.feature_store import InMemoryFeatureStore from ldclient.hook import Hook -from ldclient.impl.datasystem import Initializer, Synchronizer from ldclient.impl.util import ( log, validate_application_info, @@ -22,6 +21,8 @@ DataStoreMode, EventProcessor, FeatureStore, + Initializer, + Synchronizer, UpdateProcessor ) from ldclient.plugin import Plugin diff --git a/ldclient/impl/datasystem/config.py b/ldclient/datasystem.py similarity index 98% rename from ldclient/impl/datasystem/config.py rename to ldclient/datasystem.py index eadc6f0e..89a15e11 100644 --- a/ldclient/impl/datasystem/config.py +++ b/ldclient/datasystem.py @@ -16,8 +16,12 @@ StreamingDataSource, StreamingDataSourceBuilder ) -from ldclient.impl.datasystem import Initializer, Synchronizer -from ldclient.interfaces import DataStoreMode, FeatureStore +from ldclient.interfaces import ( + DataStoreMode, + FeatureStore, + Initializer, + Synchronizer +) T = TypeVar("T") diff --git a/ldclient/impl/datasourcev2/__init__.py b/ldclient/impl/datasourcev2/__init__.py index 1bde435b..f650e9a4 100644 --- a/ldclient/impl/datasourcev2/__init__.py +++ b/ldclient/impl/datasourcev2/__init__.py @@ -1,6 +1,6 @@ """ -This module houses FDv2 types and implementations of synchronizers and -initializers for the datasystem. +This module houses FDv2 implementations of synchronizers and initializers for +the datasystem. All types and implementations in this module are considered internal and are not part of the public API of the LaunchDarkly Python SDK. @@ -9,7 +9,3 @@ You have been warned. """ - -from .polling import PollingResult, Requester - -__all__: list[str] = ["PollingResult", "Requester"] diff --git a/ldclient/impl/datasourcev2/polling.py b/ldclient/impl/datasourcev2/polling.py index 4df2c32e..eba635a4 100644 --- a/ldclient/impl/datasourcev2/polling.py +++ b/ldclient/impl/datasourcev2/polling.py @@ -15,19 +15,10 @@ from ldclient.config import Config from ldclient.impl.datasource.feature_requester import LATEST_ALL_URI -from ldclient.impl.datasystem import BasisResult, SelectorStore, Update from ldclient.impl.datasystem.protocolv2 import ( - Basis, - ChangeSet, - ChangeSetBuilder, DeleteObject, EventName, - IntentCode, - ObjectKind, - Payload, - PutObject, - Selector, - ServerIntent + PutObject ) from ldclient.impl.http import _http_factory from ldclient.impl.repeating_task import RepeatingTask @@ -44,11 +35,22 @@ log ) from ldclient.interfaces import ( + Basis, + BasisResult, + ChangeSet, + ChangeSetBuilder, DataSourceErrorInfo, DataSourceErrorKind, - DataSourceState + DataSourceState, + Initializer, + IntentCode, + ObjectKind, + Selector, + SelectorStore, + ServerIntent, + Synchronizer, + Update ) -from ldclient.versioned_data_kind import FEATURES, SEGMENTS POLLING_ENDPOINT = "/sdk/poll" @@ -78,7 +80,7 @@ def fetch(self, selector: Optional[Selector]) -> PollingResult: CacheEntry = namedtuple("CacheEntry", ["data", "etag"]) -class PollingDataSource: +class PollingDataSource(Initializer, Synchronizer): """ PollingDataSource is a data source that can retrieve information from LaunchDarkly either as an Initializer or as a Synchronizer. @@ -235,7 +237,7 @@ def _poll(self, ss: SelectorStore) -> BasisResult: # pylint: disable=too-few-public-methods -class Urllib3PollingRequester: +class Urllib3PollingRequester(Requester): """ Urllib3PollingRequester is a Requester that uses urllib3 to make HTTP requests. @@ -401,7 +403,7 @@ def build(self) -> PollingDataSource: # pylint: disable=too-few-public-methods -class Urllib3FDv1PollingRequester: +class Urllib3FDv1PollingRequester(Requester): """ Urllib3PollingRequesterFDv1 is a Requester that uses urllib3 to make HTTP requests. diff --git a/ldclient/impl/datasourcev2/status.py b/ldclient/impl/datasourcev2/status.py deleted file mode 100644 index 05e12e56..00000000 --- a/ldclient/impl/datasourcev2/status.py +++ /dev/null @@ -1,109 +0,0 @@ -import time -from copy import copy -from typing import Callable, Optional - -from ldclient.impl.datasystem.store import Store -from ldclient.impl.listeners import Listeners -from ldclient.impl.rwlock import ReadWriteLock -from ldclient.interfaces import ( - DataSourceErrorInfo, - DataSourceState, - DataSourceStatus, - DataSourceStatusProvider, - DataStoreStatus, - DataStoreStatusProvider, - FeatureStore -) - - -class DataSourceStatusProviderImpl(DataSourceStatusProvider): - def __init__(self, listeners: Listeners): - self.__listeners = listeners - self.__status = DataSourceStatus(DataSourceState.INITIALIZING, time.time(), None) - self.__lock = ReadWriteLock() - - @property - def status(self) -> DataSourceStatus: - self.__lock.rlock() - status = self.__status - self.__lock.runlock() - - return status - - def update_status(self, new_state: DataSourceState, new_error: Optional[DataSourceErrorInfo]): - status_to_broadcast = None - - try: - self.__lock.lock() - old_status = self.__status - - if new_state == DataSourceState.INTERRUPTED and old_status.state == DataSourceState.INITIALIZING: - new_state = DataSourceState.INITIALIZING - - if new_state == old_status.state and new_error is None: - return - - new_since = self.__status.since if new_state == self.__status.state else time.time() - new_error = self.__status.error if new_error is None else new_error - - self.__status = DataSourceStatus(new_state, new_since, new_error) - - status_to_broadcast = self.__status - finally: - self.__lock.unlock() - - if status_to_broadcast is not None: - self.__listeners.notify(status_to_broadcast) - - def add_listener(self, listener: Callable[[DataSourceStatus], None]): - self.__listeners.add(listener) - - def remove_listener(self, listener: Callable[[DataSourceStatus], None]): - self.__listeners.remove(listener) - - -class DataStoreStatusProviderImpl(DataStoreStatusProvider): - def __init__(self, store: Optional[FeatureStore], listeners: Listeners): - self.__store = store - self.__listeners = listeners - - self.__lock = ReadWriteLock() - self.__status = DataStoreStatus(True, False) - - def update_status(self, status: DataStoreStatus): - """ - update_status is called from the data store to push a status update. - """ - self.__lock.lock() - modified = False - - if self.__status != status: - self.__status = status - modified = True - - self.__lock.unlock() - - if modified: - self.__listeners.notify(status) - - @property - def status(self) -> DataStoreStatus: - self.__lock.rlock() - status = copy(self.__status) - self.__lock.runlock() - - return status - - def is_monitoring_enabled(self) -> bool: - if self.__store is None: - return False - if hasattr(self.__store, "is_monitoring_enabled") is False: - return False - - return self.__store.is_monitoring_enabled() # type: ignore - - def add_listener(self, listener: Callable[[DataStoreStatus], None]): - self.__listeners.add(listener) - - def remove_listener(self, listener: Callable[[DataStoreStatus], None]): - self.__listeners.remove(listener) diff --git a/ldclient/impl/datasourcev2/streaming.py b/ldclient/impl/datasourcev2/streaming.py index c287c171..d79a341d 100644 --- a/ldclient/impl/datasourcev2/streaming.py +++ b/ldclient/impl/datasourcev2/streaming.py @@ -18,23 +18,13 @@ from ld_eventsource.errors import HTTPStatusError from ldclient.config import Config -from ldclient.impl.datasystem import ( - DiagnosticAccumulator, - DiagnosticSource, - SelectorStore, - Synchronizer, - Update -) +from ldclient.impl.datasystem import DiagnosticAccumulator, DiagnosticSource from ldclient.impl.datasystem.protocolv2 import ( - ChangeSetBuilder, DeleteObject, Error, EventName, Goodbye, - IntentCode, - PutObject, - Selector, - ServerIntent + PutObject ) from ldclient.impl.http import HTTPFactory, _http_factory from ldclient.impl.util import ( @@ -45,9 +35,16 @@ log ) from ldclient.interfaces import ( + ChangeSetBuilder, DataSourceErrorInfo, DataSourceErrorKind, - DataSourceState + DataSourceState, + IntentCode, + Selector, + SelectorStore, + ServerIntent, + Synchronizer, + Update ) # allows for up to 5 minutes to elapse without any data sent across the stream. diff --git a/ldclient/impl/datasystem/__init__.py b/ldclient/impl/datasystem/__init__.py index 1d299944..c7a36829 100644 --- a/ldclient/impl/datasystem/__init__.py +++ b/ldclient/impl/datasystem/__init__.py @@ -4,16 +4,11 @@ """ from abc import abstractmethod -from dataclasses import dataclass from enum import Enum from threading import Event -from typing import Generator, Optional, Protocol, runtime_checkable +from typing import Protocol, runtime_checkable -from ldclient.impl.datasystem.protocolv2 import Basis, ChangeSet, Selector -from ldclient.impl.util import _Result from ldclient.interfaces import ( - DataSourceErrorInfo, - DataSourceState, DataSourceStatusProvider, DataStoreStatusProvider, FlagTracker, @@ -170,102 +165,3 @@ def set_diagnostic_accumulator(self, diagnostic_accumulator: DiagnosticAccumulat Set the diagnostic_accumulator to be used for reporting diagnostic events. """ raise NotImplementedError - - -class SelectorStore(Protocol): - """ - SelectorStore represents a component capable of providing Selectors - for data retrieval. - """ - - @abstractmethod - def selector(self) -> Selector: - """ - get_selector should return a Selector object that defines the criteria - for data retrieval. - """ - raise NotImplementedError - - -BasisResult = _Result[Basis, str] - - -class Initializer(Protocol): # pylint: disable=too-few-public-methods - """ - Initializer represents a component capable of retrieving a single data - result, such as from the LD polling API. - - The intent of initializers is to quickly fetch an initial set of data, - which may be stale but is fast to retrieve. This initial data serves as a - foundation for a Synchronizer to build upon, enabling it to provide updates - as new changes occur. - """ - - @property - @abstractmethod - def name(self) -> str: - """ - Returns the name of the initializer, which is used for logging and debugging. - """ - raise NotImplementedError - - @abstractmethod - def fetch(self, ss: SelectorStore) -> BasisResult: - """ - fetch should retrieve the initial data set for the data source, returning - a Basis object on success, or an error message on failure. - - :param ss: A SelectorStore that provides the Selector to use as a basis for data retrieval. - """ - raise NotImplementedError - - -@dataclass(frozen=True) -class Update: - """ - Update represents the results of a synchronizer's ongoing sync - method. - """ - - state: DataSourceState - change_set: Optional[ChangeSet] = None - error: Optional[DataSourceErrorInfo] = None - revert_to_fdv1: bool = False - environment_id: Optional[str] = None - - -class Synchronizer(Protocol): # pylint: disable=too-few-public-methods - """ - Synchronizer represents a component capable of synchronizing data from an external - data source, such as a streaming or polling API. - - It is responsible for yielding Update objects that represent the current state - of the data source, including any changes that have occurred since the last - synchronization. - """ - @property - @abstractmethod - def name(self) -> str: - """ - Returns the name of the synchronizer, which is used for logging and debugging. - """ - raise NotImplementedError - - @abstractmethod - def sync(self, ss: SelectorStore) -> Generator[Update, None, None]: - """ - sync should begin the synchronization process for the data source, yielding - Update objects until the connection is closed or an unrecoverable error - occurs. - - :param ss: A SelectorStore that provides the Selector to use as a basis for data retrieval. - """ - raise NotImplementedError - - @abstractmethod - def stop(self): - """ - stop should halt the synchronization process, causing the sync method - to exit as soon as possible. - """ - raise NotImplementedError diff --git a/ldclient/impl/datasystem/fdv1.py b/ldclient/impl/datasystem/fdv1.py index 023c1fc4..32af49d6 100644 --- a/ldclient/impl/datasystem/fdv1.py +++ b/ldclient/impl/datasystem/fdv1.py @@ -13,7 +13,11 @@ DataStoreStatusProviderImpl, DataStoreUpdateSinkImpl ) -from ldclient.impl.datasystem import DataAvailability, DiagnosticAccumulator +from ldclient.impl.datasystem import ( + DataAvailability, + DataSystem, + DiagnosticAccumulator +) from ldclient.impl.flag_tracker import FlagTrackerImpl from ldclient.impl.listeners import Listeners from ldclient.impl.stubs import NullUpdateProcessor @@ -31,7 +35,7 @@ # Delayed import inside __init__ to avoid circular dependency with ldclient.client -class FDv1: +class FDv1(DataSystem): """ FDv1 wires the existing v1 data source and store behavior behind the generic DataSystem surface. diff --git a/ldclient/impl/datasystem/fdv2.py b/ldclient/impl/datasystem/fdv2.py index 64d26c77..21f95c0a 100644 --- a/ldclient/impl/datasystem/fdv2.py +++ b/ldclient/impl/datasystem/fdv2.py @@ -1,20 +1,16 @@ -import logging import time -from queue import Empty, Queue +from copy import copy +from queue import Queue from threading import Event, Thread from typing import Any, Callable, Dict, List, Mapping, Optional from ldclient.config import Builder, Config, DataSystemConfig from ldclient.feature_store import _FeatureStoreDataSetSorter -from ldclient.impl.datasourcev2.status import ( - DataSourceStatusProviderImpl, - DataStoreStatusProviderImpl -) from ldclient.impl.datasystem import ( DataAvailability, + DataSystem, DiagnosticAccumulator, - DiagnosticSource, - Synchronizer + DiagnosticSource ) from ldclient.impl.datasystem.store import Store from ldclient.impl.flag_tracker import FlagTrackerImpl @@ -23,6 +19,7 @@ from ldclient.impl.rwlock import ReadWriteLock from ldclient.impl.util import _Fail, log from ldclient.interfaces import ( + DataSourceErrorInfo, DataSourceState, DataSourceStatus, DataSourceStatusProvider, @@ -31,11 +28,105 @@ DataStoreStatusProvider, FeatureStore, FlagTracker, - ReadOnlyStore + ReadOnlyStore, + Synchronizer ) from ldclient.versioned_data_kind import VersionedDataKind +class DataSourceStatusProviderImpl(DataSourceStatusProvider): + def __init__(self, listeners: Listeners): + self.__listeners = listeners + self.__status = DataSourceStatus(DataSourceState.INITIALIZING, time.time(), None) + self.__lock = ReadWriteLock() + + @property + def status(self) -> DataSourceStatus: + self.__lock.rlock() + status = self.__status + self.__lock.runlock() + + return status + + def update_status(self, new_state: DataSourceState, new_error: Optional[DataSourceErrorInfo]): + status_to_broadcast = None + + try: + self.__lock.lock() + old_status = self.__status + + if new_state == DataSourceState.INTERRUPTED and old_status.state == DataSourceState.INITIALIZING: + new_state = DataSourceState.INITIALIZING + + if new_state == old_status.state and new_error is None: + return + + new_since = self.__status.since if new_state == self.__status.state else time.time() + new_error = self.__status.error if new_error is None else new_error + + self.__status = DataSourceStatus(new_state, new_since, new_error) + + status_to_broadcast = self.__status + finally: + self.__lock.unlock() + + if status_to_broadcast is not None: + self.__listeners.notify(status_to_broadcast) + + def add_listener(self, listener: Callable[[DataSourceStatus], None]): + self.__listeners.add(listener) + + def remove_listener(self, listener: Callable[[DataSourceStatus], None]): + self.__listeners.remove(listener) + + +class DataStoreStatusProviderImpl(DataStoreStatusProvider): + def __init__(self, store: Optional[FeatureStore], listeners: Listeners): + self.__store = store + self.__listeners = listeners + + self.__lock = ReadWriteLock() + self.__status = DataStoreStatus(True, False) + + def update_status(self, status: DataStoreStatus): + """ + update_status is called from the data store to push a status update. + """ + self.__lock.lock() + modified = False + + if self.__status != status: + self.__status = status + modified = True + + self.__lock.unlock() + + if modified: + self.__listeners.notify(status) + + @property + def status(self) -> DataStoreStatus: + self.__lock.rlock() + status = copy(self.__status) + self.__lock.runlock() + + return status + + def is_monitoring_enabled(self) -> bool: + if self.__store is None: + return False + if hasattr(self.__store, "is_monitoring_enabled") is False: + return False + + return self.__store.is_monitoring_enabled() # type: ignore + + def add_listener(self, listener: Callable[[DataStoreStatus], None]): + self.__listeners.add(listener) + + def remove_listener(self, listener: Callable[[DataStoreStatus], None]): + self.__listeners.remove(listener) + + class FeatureStoreClientWrapper(FeatureStore): """Provides additional behavior that the client requires before or after feature store operations. Currently this just means sorting the data set for init() and dealing with data store status listeners. @@ -151,7 +242,7 @@ def is_monitoring_enabled(self) -> bool: return monitoring_enabled() -class FDv2: +class FDv2(DataSystem): """ FDv2 is an implementation of the DataSystem interface that uses the Flag Delivery V2 protocol for obtaining and keeping data up-to-date. Additionally, it operates with an optional persistent diff --git a/ldclient/impl/datasystem/protocolv2.py b/ldclient/impl/datasystem/protocolv2.py index c26ad746..55736430 100644 --- a/ldclient/impl/datasystem/protocolv2.py +++ b/ldclient/impl/datasystem/protocolv2.py @@ -3,185 +3,9 @@ LaunchDarkly data system version 2 (FDv2). """ -from abc import abstractmethod from dataclasses import dataclass -from enum import Enum -from typing import TYPE_CHECKING, Generator, List, Optional, Protocol -from ldclient.impl.util import Result - -if TYPE_CHECKING: - from ldclient.impl.datasystem import SelectorStore, Update - - -class EventName(str, Enum): - """ - EventName represents the name of an event that can be sent by the server for FDv2. - """ - - PUT_OBJECT = "put-object" - """ - Specifies that an object should be added to the data set with upsert semantics. - """ - - DELETE_OBJECT = "delete-object" - """ - Specifies that an object should be removed from the data set. - """ - - SERVER_INTENT = "server-intent" - """ - Specifies the server's intent. - """ - - PAYLOAD_TRANSFERRED = "payload-transferred" - """ - Specifies that that all data required to bring the existing data set to - a new version has been transferred. - """ - - HEARTBEAT = "heart-beat" - """ - Keeps the connection alive. - """ - - GOODBYE = "goodbye" - """ - Specifies that the server is about to close the connection. - """ - - ERROR = "error" - """ - Specifies that an error occurred while serving the connection. - """ - - -class IntentCode(str, Enum): - """ - IntentCode represents the various intents that can be sent by the server. - - This type is not stable, and not subject to any backwards - compatibility guarantees or semantic versioning. It is not suitable for production usage. - - Do not use it. - You have been warned. - """ - - TRANSFER_FULL = "xfer-full" - """ - The server intends to send a full data set. - """ - TRANSFER_CHANGES = "xfer-changes" - """ - The server intends to send only the necessary changes to bring an existing - data set up-to-date. - """ - - TRANSFER_NONE = "none" - """ - The server intends to send no data (payload is up to date). - """ - - -@dataclass(frozen=True) -class Payload: - """ - Payload represents a payload delivered in a streaming response. - - This type is not stable, and not subject to any backwards - compatibility guarantees or semantic versioning. It is not suitable for production usage. - - Do not use it. - You have been warned. - """ - - id: str - target: int - code: IntentCode - reason: str - - def to_dict(self) -> dict: - """ - Serializes the Payload to a JSON-compatible dictionary. - """ - return { - "id": self.id, - "target": self.target, - "intentCode": self.code.value, - "reason": self.reason, - } - - @staticmethod - def from_dict(data: dict) -> "Payload": - """ - Create a Payload from a dictionary representation. - """ - intent_code = data.get("intentCode") - - if intent_code is None or not isinstance(intent_code, str): - raise ValueError( - "Invalid data for Payload: 'intentCode' key is missing or not a string" - ) - - return Payload( - id=data.get("id", ""), - target=data.get("target", 0), - code=IntentCode(intent_code), - reason=data.get("reason", ""), - ) - - -@dataclass(frozen=True) -class ServerIntent: - """ - ServerIntent represents the type of change associated with the payload - (e.g., transfer full, transfer changes, etc.) - """ - - payload: Payload - - def to_dict(self) -> dict: - """ - Serializes the ServerIntent to a JSON-compatible dictionary. - """ - return { - "payloads": [self.payload.to_dict()], - } - - @staticmethod - def from_dict(data: dict) -> "ServerIntent": - """ - Create a ServerIntent from a dictionary representation. - """ - if "payloads" not in data or not isinstance(data["payloads"], list): - raise ValueError( - "Invalid data for ServerIntent: 'payloads' key is missing or not a list" - ) - if len(data["payloads"]) != 1: - raise ValueError( - "Invalid data for ServerIntent: expected exactly one payload" - ) - - payload = data["payloads"][0] - if not isinstance(payload, dict): - raise ValueError("Invalid payload in ServerIntent: expected a dictionary") - - return ServerIntent(payload=Payload.from_dict(payload)) - - -class ObjectKind(str, Enum): - """ - ObjectKind represents the kind of object. - - This type is not stable, and not subject to any backwards - compatibility guarantees or semantic versioning. It is not suitable for production usage. - - Do not use it. - You have been warned. - """ - - FLAG = "flag" - SEGMENT = "segment" +from ldclient.interfaces import EventName, ObjectKind @dataclass(frozen=True) @@ -360,289 +184,3 @@ def from_dict(data: dict) -> "Error": raise ValueError("Missing required fields in Error JSON.") return Error(payload_id=payload_id, reason=reason) - - -@dataclass(frozen=True) -class Selector: - """ - Selector represents a particular snapshot of data. - - This type is not stable, and not subject to any backwards - compatibility guarantees or semantic versioning. It is not suitable for production usage. - - Do not use it. - You have been warned. - """ - - state: str = "" - version: int = 0 - - @staticmethod - def no_selector() -> "Selector": - """ - Returns an empty Selector. - """ - return Selector() - - def is_defined(self) -> bool: - """ - Returns True if the Selector has a value. - """ - return self != Selector.no_selector() - - def name(self) -> str: - """ - Event method. - """ - return EventName.PAYLOAD_TRANSFERRED - - @staticmethod - def new_selector(state: str, version: int) -> "Selector": - """ - Creates a new Selector from a state string and version. - """ - return Selector(state=state, version=version) - - def to_dict(self) -> dict: - """ - Serializes the Selector to a JSON-compatible dictionary. - """ - return {"state": self.state, "version": self.version} - - @staticmethod - def from_dict(data: dict) -> "Selector": - """ - Deserializes a Selector from a JSON-compatible dictionary. - """ - state = data.get("state") - version = data.get("version") - - if state is None or version is None: - raise ValueError("Missing required fields in Selector JSON.") - - return Selector(state=state, version=version) - - -class ChangeType(Enum): - """ - ChangeType specifies if an object is being upserted or deleted. - - This type is not stable, and not subject to any backwards - compatibility guarantees or semantic versioning. It is not suitable for production usage. - - Do not use it. - You have been warned. - """ - - PUT = "put" - """ - Represents an object being upserted. - """ - - DELETE = "delete" - """ - Represents an object being deleted. - """ - - -@dataclass(frozen=True) -class Change: - """ - Change represents a change to a piece of data, such as an update or deletion. - - This type is not stable, and not subject to any backwards - compatibility guarantees or semantic versioning. It is not suitable for production usage. - - Do not use it. - You have been warned. - """ - - action: ChangeType - kind: ObjectKind - key: str - version: int - object: Optional[dict] = None - - -@dataclass(frozen=True) -class ChangeSet: - """ - ChangeSet represents a list of changes to be applied. - - This type is not stable, and not subject to any backwards - compatibility guarantees or semantic versioning. It is not suitable for production usage. - - Do not use it. - You have been warned. - """ - - intent_code: IntentCode - changes: List[Change] - selector: Optional[Selector] - - -@dataclass(frozen=True) -class Basis: - """ - Basis represents the initial payload of data that a data source can - provide. Initializers provide this via fetch, whereas Synchronizers provide - it asynchronously. - """ - - change_set: ChangeSet - persist: bool - environment_id: Optional[str] = None - - -class Synchronizer(Protocol): - """ - Represents a component capable of obtaining a Basis and subsequent delta - updates asynchronously. - """ - - @abstractmethod - def name(self) -> str: - """Returns the name of the initializer.""" - raise NotImplementedError - - def sync(self, ss: "SelectorStore") -> Generator["Update", None, None]: - """ - sync should begin the synchronization process for the data source, yielding - Update objects until the connection is closed or an unrecoverable error - occurs. - """ - raise NotImplementedError - - def close(self): - """ - Close the synchronizer, releasing any resources it holds. - """ - - -class Initializer(Protocol): - """ - Represents a component capable of obtaining a Basis via a synchronous call. - """ - - @abstractmethod - def name(self) -> str: - """Returns the name of the initializer.""" - raise NotImplementedError - - @abstractmethod - def fetch(self) -> Result: - """ - Fetch returns a Basis, or an error if the Basis could not be retrieved. - """ - raise NotImplementedError - - -class ChangeSetBuilder: - """ - ChangeSetBuilder is a helper for constructing a ChangeSet. - - This type is not stable, and not subject to any backwards - compatibility guarantees or semantic versioning. It is not suitable for production usage. - - Do not use it. - You have been warned. - """ - - def __init__(self): - """ - Initializes a new ChangeSetBuilder. - """ - self.intent = None - self.changes = [] - - @staticmethod - def no_changes() -> "ChangeSet": - """ - Represents an intent that the current data is up-to-date and doesn't - require changes. - """ - return ChangeSet( - intent_code=IntentCode.TRANSFER_NONE, selector=None, changes=[] - ) - - @staticmethod - def empty(selector) -> "ChangeSet": - """ - Returns an empty ChangeSet, which is useful for initializing a client - without data or for clearing out all existing data. - """ - return ChangeSet( - intent_code=IntentCode.TRANSFER_FULL, selector=selector, changes=[] - ) - - def start(self, intent: IntentCode): - """ - Begins a new change set with a given intent. - """ - self.intent = intent - self.changes = [] - - def expect_changes(self): - """ - Ensures that the current ChangeSetBuilder is prepared to handle changes. - - If a data source's initial connection reflects an updated status, we - need to keep the provided server intent. This allows subsequent changes - to come down the line without an explicit server intent. - - However, to maintain logical consistency, we need to ensure that the intent - is set to IntentTransferChanges. - """ - if self.intent is None: - raise ValueError("changeset: cannot expect changes without a server-intent") - - if self.intent != IntentCode.TRANSFER_NONE: - return - - self.intent = IntentCode.TRANSFER_CHANGES - - def reset(self): - """ - Clears any existing changes while preserving the current intent. - """ - self.changes = [] - - def finish(self, selector) -> ChangeSet: - """ - Identifies a changeset with a selector and returns the completed - changeset. Clears any existing changes while preserving the current - intent, so the builder can be reused. - """ - if self.intent is None: - raise ValueError("changeset: cannot complete without a server-intent") - - changeset = ChangeSet( - intent_code=self.intent, selector=selector, changes=self.changes - ) - self.changes = [] - - # Once a full transfer has been processed, all future changes should be - # assumed to be changes. Flag delivery can override this behavior by - # sending a new server intent to any connected stream. - if self.intent == IntentCode.TRANSFER_FULL: - self.intent = IntentCode.TRANSFER_CHANGES - - return changeset - - def add_put(self, kind, key, version, obj): - """ - Adds a new object to the changeset. - """ - self.changes.append( - Change( - action=ChangeType.PUT, kind=kind, key=key, version=version, object=obj - ) - ) - - def add_delete(self, kind, key, version): - """ - Adds a deletion to the changeset. - """ - self.changes.append( - Change(action=ChangeType.DELETE, kind=kind, key=key, version=version) - ) diff --git a/ldclient/impl/datasystem/store.py b/ldclient/impl/datasystem/store.py index 49f0a70a..0d731e03 100644 --- a/ldclient/impl/datasystem/store.py +++ b/ldclient/impl/datasystem/store.py @@ -10,24 +10,22 @@ from collections import defaultdict from typing import Any, Callable, Dict, List, Optional, Set -from ldclient.impl.datasystem.protocolv2 import ( - Change, - ChangeSet, - ChangeType, - IntentCode, - ObjectKind, - Selector -) from ldclient.impl.dependency_tracker import DependencyTracker, KindAndKey from ldclient.impl.listeners import Listeners from ldclient.impl.model.entity import ModelEntity from ldclient.impl.rwlock import ReadWriteLock from ldclient.impl.util import log from ldclient.interfaces import ( + Change, + ChangeSet, + ChangeType, DataStoreStatusProvider, FeatureStore, FlagChange, - ReadOnlyStore + IntentCode, + ObjectKind, + ReadOnlyStore, + Selector ) from ldclient.versioned_data_kind import FEATURES, SEGMENTS, VersionedDataKind diff --git a/ldclient/impl/integrations/files/file_data_sourcev2.py b/ldclient/impl/integrations/files/file_data_sourcev2.py index c8e152b7..5ea976ed 100644 --- a/ldclient/impl/integrations/files/file_data_sourcev2.py +++ b/ldclient/impl/integrations/files/file_data_sourcev2.py @@ -5,25 +5,26 @@ from queue import Empty, Queue from typing import Generator -from ldclient.impl.datasystem import BasisResult, SelectorStore, Update -from ldclient.impl.datasystem.protocolv2 import ( - Basis, - ChangeSetBuilder, - IntentCode, - ObjectKind, - Selector -) from ldclient.impl.repeating_task import RepeatingTask from ldclient.impl.util import _Fail, _Success, current_time_millis, log from ldclient.interfaces import ( + Basis, + BasisResult, + ChangeSetBuilder, DataSourceErrorInfo, DataSourceErrorKind, - DataSourceState + DataSourceState, + IntentCode, + ObjectKind, + Selector, + SelectorStore, + Update ) have_yaml = False try: import yaml + have_yaml = True except ImportError: pass @@ -33,14 +34,15 @@ import watchdog import watchdog.events import watchdog.observers + have_watchdog = True except ImportError: pass def _sanitize_json_item(item): - if not ('version' in item): - item['version'] = 1 + if not ("version" in item): + item["version"] = 1 class _FileDataSourceV2: @@ -105,16 +107,12 @@ def fetch(self, ss: SelectorStore) -> BasisResult: change_set = result.value - basis = Basis( - change_set=change_set, - persist=False, - environment_id=None - ) + basis = Basis(change_set=change_set, persist=False, environment_id=None) return _Success(basis) except Exception as e: - log.error('Error fetching file data: %s' % repr(e)) + log.error("Error fetching file data: %s" % repr(e)) traceback.print_exc() return _Fail(f"Error fetching file data: {str(e)}") @@ -137,15 +135,14 @@ def sync(self, ss: SelectorStore) -> Generator[Update, None, None]: kind=DataSourceErrorKind.INVALID_DATA, status_code=0, time=current_time_millis(), - message=initial_result.error - ) + message=initial_result.error, + ), ) return # Yield the initial successful state yield Update( - state=DataSourceState.VALID, - change_set=initial_result.value.change_set + state=DataSourceState.VALID, change_set=initial_result.value.change_set ) # Start watching for file changes @@ -168,7 +165,7 @@ def sync(self, ss: SelectorStore) -> Generator[Update, None, None]: yield update except Exception as e: - log.error('Error in file data synchronizer: %s' % repr(e)) + log.error("Error in file data synchronizer: %s" % repr(e)) traceback.print_exc() yield Update( state=DataSourceState.OFF, @@ -176,8 +173,8 @@ def sync(self, ss: SelectorStore) -> Generator[Update, None, None]: kind=DataSourceErrorKind.UNKNOWN, status_code=0, time=current_time_millis(), - message=f"Error in file data synchronizer: {str(e)}" - ) + message=f"Error in file data synchronizer: {str(e)}", + ), ) break @@ -221,19 +218,13 @@ def _load_all_to_changeset(self): # Add all flags to the changeset for key, flag_data in flags_dict.items(): builder.add_put( - ObjectKind.FLAG, - key, - flag_data.get('version', 1), - flag_data + ObjectKind.FLAG, key, flag_data.get("version", 1), flag_data ) # Add all segments to the changeset for key, segment_data in segments_dict.items(): builder.add_put( - ObjectKind.SEGMENT, - key, - segment_data.get('version', 1), - segment_data + ObjectKind.SEGMENT, key, segment_data.get("version", 1), segment_data ) # Use no_selector since we don't have versioning information from files @@ -250,20 +241,20 @@ def _load_file(self, path, flags_dict, segments_dict): :param segments_dict: dictionary to add segments to """ content = None - with open(path, 'r') as f: + with open(path, "r") as f: content = f.read() parsed = self._parse_content(content) - for key, flag in parsed.get('flags', {}).items(): + for key, flag in parsed.get("flags", {}).items(): _sanitize_json_item(flag) - self._add_item(flags_dict, 'flags', flag) + self._add_item(flags_dict, "flags", flag) - for key, value in parsed.get('flagValues', {}).items(): - self._add_item(flags_dict, 'flags', self._make_flag_with_value(key, value)) + for key, value in parsed.get("flagValues", {}).items(): + self._add_item(flags_dict, "flags", self._make_flag_with_value(key, value)) - for key, segment in parsed.get('segments', {}).items(): + for key, segment in parsed.get("segments", {}).items(): _sanitize_json_item(segment) - self._add_item(segments_dict, 'segments', segment) + self._add_item(segments_dict, "segments", segment) def _parse_content(self, content): """ @@ -284,11 +275,13 @@ def _add_item(self, items_dict, kind_name, item): :param kind_name: name of the kind (for error messages) :param item: item to add """ - key = item.get('key') + key = item.get("key") if items_dict.get(key) is None: items_dict[key] = item else: - raise Exception('In %s, key "%s" was used more than once' % (kind_name, key)) + raise Exception( + 'In %s, key "%s" was used more than once' % (kind_name, key) + ) def _make_flag_with_value(self, key, value): """ @@ -298,7 +291,13 @@ def _make_flag_with_value(self, key, value): :param value: flag value :return: flag dictionary """ - return {'key': key, 'version': 1, 'on': True, 'fallthrough': {'variation': 0}, 'variations': [value]} + return { + "key": key, + "version": 1, + "on": True, + "fallthrough": {"variation": 0}, + "variations": [value], + } def _start_auto_updater(self): """ @@ -311,12 +310,17 @@ def _start_auto_updater(self): try: resolved_paths.append(os.path.realpath(path)) except Exception: - log.warning('Cannot watch for changes to data file "%s" because it is an invalid path' % path) + log.warning( + 'Cannot watch for changes to data file "%s" because it is an invalid path' + % path + ) if have_watchdog and not self._force_polling: return _WatchdogAutoUpdaterV2(resolved_paths, self._on_file_change) else: - return _PollingAutoUpdaterV2(resolved_paths, self._on_file_change, self._poll_interval) + return _PollingAutoUpdaterV2( + resolved_paths, self._on_file_change, self._poll_interval + ) def _on_file_change(self): """ @@ -340,20 +344,19 @@ def _on_file_change(self): kind=DataSourceErrorKind.INVALID_DATA, status_code=0, time=current_time_millis(), - message=result.error - ) + message=result.error, + ), ) self._update_queue.put(error_update) else: # Queue a successful update update = Update( - state=DataSourceState.VALID, - change_set=result.value + state=DataSourceState.VALID, change_set=result.value ) self._update_queue.put(update) except Exception as e: - log.error('Error processing file change: %s' % repr(e)) + log.error("Error processing file change: %s" % repr(e)) traceback.print_exc() error_update = Update( state=DataSourceState.INTERRUPTED, @@ -361,8 +364,8 @@ def _on_file_change(self): kind=DataSourceErrorKind.UNKNOWN, status_code=0, time=current_time_millis(), - message=f"Error processing file change: {str(e)}" - ) + message=f"Error processing file change: {str(e)}", + ), ) self._update_queue.put(error_update) @@ -400,7 +403,9 @@ def __init__(self, resolved_paths, on_change_callback, interval): self._paths = resolved_paths self._on_change = on_change_callback self._file_times = self._check_file_times() - self._timer = RepeatingTask("ldclient.datasource.filev2.poll", interval, interval, self._poll) + self._timer = RepeatingTask( + "ldclient.datasource.filev2.poll", interval, interval, self._poll + ) self._timer.start() def stop(self): @@ -410,7 +415,10 @@ def _poll(self): new_times = self._check_file_times() changed = False for file_path, file_time in self._file_times.items(): - if new_times.get(file_path) is not None and new_times.get(file_path) != file_time: + if ( + new_times.get(file_path) is not None + and new_times.get(file_path) != file_time + ): changed = True break self._file_times = new_times @@ -423,6 +431,8 @@ def _check_file_times(self): try: ret[path] = os.path.getmtime(path) except Exception: - log.warning("Failed to get modification time for %s. Setting to None", path) + log.warning( + "Failed to get modification time for %s. Setting to None", path + ) ret[path] = None return ret diff --git a/ldclient/impl/integrations/test_datav2/test_data_sourcev2.py b/ldclient/impl/integrations/test_datav2/test_data_sourcev2.py index 6d8edacc..5e5b90d6 100644 --- a/ldclient/impl/integrations/test_datav2/test_data_sourcev2.py +++ b/ldclient/impl/integrations/test_datav2/test_data_sourcev2.py @@ -2,21 +2,20 @@ from queue import Empty, Queue from typing import Generator -from ldclient.impl.datasystem import BasisResult, SelectorStore, Update -from ldclient.impl.datasystem.protocolv2 import ( - Basis, - ChangeSetBuilder, - IntentCode, - ObjectKind, - Selector -) from ldclient.impl.util import _Fail, _Success, current_time_millis from ldclient.interfaces import ( + Basis, + BasisResult, + ChangeSetBuilder, DataSourceErrorInfo, DataSourceErrorKind, - DataSourceState + DataSourceState, + IntentCode, + ObjectKind, + Selector, + SelectorStore, + Update ) -from ldclient.testing.mock_components import MockSelectorStore class _TestDataSourceV2: @@ -70,21 +69,14 @@ def fetch(self, ss: SelectorStore) -> BasisResult: # Add all flags to the changeset for key, flag_data in init_data.items(): builder.add_put( - ObjectKind.FLAG, - key, - flag_data.get('version', 1), - flag_data + ObjectKind.FLAG, key, flag_data.get("version", 1), flag_data ) # Create selector for this version selector = Selector.new_selector(str(version), version) change_set = builder.finish(selector) - basis = Basis( - change_set=change_set, - persist=False, - environment_id=None - ) + basis = Basis(change_set=change_set, persist=False, environment_id=None) return _Success(basis) @@ -107,15 +99,14 @@ def sync(self, ss: SelectorStore) -> Generator[Update, None, None]: kind=DataSourceErrorKind.STORE_ERROR, status_code=0, time=current_time_millis(), - message=initial_result.error - ) + message=initial_result.error, + ), ) return # Yield the initial successful state yield Update( - state=DataSourceState.VALID, - change_set=initial_result.value.change_set + state=DataSourceState.VALID, change_set=initial_result.value.change_set ) # Continue yielding updates as they arrive @@ -139,8 +130,8 @@ def sync(self, ss: SelectorStore) -> Generator[Update, None, None]: kind=DataSourceErrorKind.UNKNOWN, status_code=0, time=current_time_millis(), - message=f"Error in test data synchronizer: {str(e)}" - ) + message=f"Error in test data synchronizer: {str(e)}", + ), ) break @@ -176,9 +167,9 @@ def upsert_flag(self, flag_data: dict): # Add the updated flag builder.add_put( ObjectKind.FLAG, - flag_data['key'], - flag_data.get('version', 1), - flag_data + flag_data["key"], + flag_data.get("version", 1), + flag_data, ) # Create selector for this version @@ -186,10 +177,7 @@ def upsert_flag(self, flag_data: dict): change_set = builder.finish(selector) # Queue the update - update = Update( - state=DataSourceState.VALID, - change_set=change_set - ) + update = Update(state=DataSourceState.VALID, change_set=change_set) self._update_queue.put(update) @@ -201,7 +189,7 @@ def upsert_flag(self, flag_data: dict): kind=DataSourceErrorKind.STORE_ERROR, status_code=0, time=current_time_millis(), - message=f"Error processing flag update: {str(e)}" - ) + message=f"Error processing flag update: {str(e)}", + ), ) self._update_queue.put(error_update) diff --git a/ldclient/interfaces.py b/ldclient/interfaces.py index 307d5545..7a030d30 100644 --- a/ldclient/interfaces.py +++ b/ldclient/interfaces.py @@ -3,13 +3,14 @@ They may be useful in writing new implementations of these components, or for testing. """ - from abc import ABCMeta, abstractmethod, abstractproperty +from dataclasses import dataclass from enum import Enum -from typing import Any, Callable, Mapping, Optional, Protocol +from typing import Any, Callable, Generator, List, Mapping, Optional, Protocol from ldclient.context import Context from ldclient.impl.listeners import Listeners +from ldclient.impl.util import _Result from .versioned_data_kind import VersionedDataKind @@ -1115,3 +1116,557 @@ def remove_listener(self, listener: Callable[[DataStoreStatus], None]): :param listener: the listener to remove; if no such listener was added, this does nothing """ + + +class EventName(str, Enum): + """ + EventName represents the name of an event that can be sent by the server for FDv2. + + This type is not stable, and not subject to any backwards + compatibility guarantees or semantic versioning. It is not suitable for production usage. + + Do not use it. + You have been warned. + """ + + PUT_OBJECT = "put-object" + """ + Specifies that an object should be added to the data set with upsert semantics. + """ + + DELETE_OBJECT = "delete-object" + """ + Specifies that an object should be removed from the data set. + """ + + SERVER_INTENT = "server-intent" + """ + Specifies the server's intent. + """ + + PAYLOAD_TRANSFERRED = "payload-transferred" + """ + Specifies that that all data required to bring the existing data set to + a new version has been transferred. + """ + + HEARTBEAT = "heart-beat" + """ + Keeps the connection alive. + """ + + GOODBYE = "goodbye" + """ + Specifies that the server is about to close the connection. + """ + + ERROR = "error" + """ + Specifies that an error occurred while serving the connection. + """ + + +@dataclass(frozen=True) +class Selector: + """ + Selector represents a particular snapshot of data. + + This type is not stable, and not subject to any backwards + compatibility guarantees or semantic versioning. It is not suitable for production usage. + + Do not use it. + You have been warned. + """ + + state: str = "" + version: int = 0 + + @staticmethod + def no_selector() -> "Selector": + """ + Returns an empty Selector. + """ + return Selector() + + def is_defined(self) -> bool: + """ + Returns True if the Selector has a value. + """ + return self != Selector.no_selector() + + def name(self) -> str: + """ + Event method. + """ + return EventName.PAYLOAD_TRANSFERRED + + @staticmethod + def new_selector(state: str, version: int) -> "Selector": + """ + Creates a new Selector from a state string and version. + """ + return Selector(state=state, version=version) + + def to_dict(self) -> dict: + """ + Serializes the Selector to a JSON-compatible dictionary. + """ + return {"state": self.state, "version": self.version} + + @staticmethod + def from_dict(data: dict) -> "Selector": + """ + Deserializes a Selector from a JSON-compatible dictionary. + """ + state = data.get("state") + version = data.get("version") + + if state is None or version is None: + raise ValueError("Missing required fields in Selector JSON.") + + return Selector(state=state, version=version) + + +class ChangeType(Enum): + """ + ChangeType specifies if an object is being upserted or deleted. + + This type is not stable, and not subject to any backwards + compatibility guarantees or semantic versioning. It is not suitable for production usage. + + Do not use it. + You have been warned. + """ + + PUT = "put" + """ + Represents an object being upserted. + """ + + DELETE = "delete" + """ + Represents an object being deleted. + """ + + +class ObjectKind(str, Enum): + """ + ObjectKind represents the kind of object. + + This type is not stable, and not subject to any backwards + compatibility guarantees or semantic versioning. It is not suitable for production usage. + + Do not use it. + You have been warned. + """ + + FLAG = "flag" + SEGMENT = "segment" + + +@dataclass(frozen=True) +class Change: + """ + Change represents a change to a piece of data, such as an update or deletion. + + This type is not stable, and not subject to any backwards + compatibility guarantees or semantic versioning. It is not suitable for production usage. + + Do not use it. + You have been warned. + """ + + action: ChangeType + kind: ObjectKind + key: str + version: int + object: Optional[dict] = None + + +class IntentCode(str, Enum): + """ + IntentCode represents the various intents that can be sent by the server. + + This type is not stable, and not subject to any backwards + compatibility guarantees or semantic versioning. It is not suitable for production usage. + + Do not use it. + You have been warned. + """ + + TRANSFER_FULL = "xfer-full" + """ + The server intends to send a full data set. + """ + TRANSFER_CHANGES = "xfer-changes" + """ + The server intends to send only the necessary changes to bring an existing + data set up-to-date. + """ + + TRANSFER_NONE = "none" + """ + The server intends to send no data (payload is up to date). + """ + + +@dataclass(frozen=True) +class ChangeSet: + """ + ChangeSet represents a list of changes to be applied. + + This type is not stable, and not subject to any backwards + compatibility guarantees or semantic versioning. It is not suitable for production usage. + + Do not use it. + You have been warned. + """ + + intent_code: IntentCode + changes: List[Change] + selector: Optional[Selector] + + +@dataclass(frozen=True) +class Basis: + """ + Basis represents the initial payload of data that a data source can + provide. Initializers provide this via fetch, whereas Synchronizers provide + it asynchronously. + + This type is not stable, and not subject to any backwards + compatibility guarantees or semantic versioning. It is not suitable for production usage. + + Do not use it. + You have been warned. + """ + + change_set: ChangeSet + persist: bool + environment_id: Optional[str] = None + + +class ChangeSetBuilder: + """ + ChangeSetBuilder is a helper for constructing a ChangeSet. + + This type is not stable, and not subject to any backwards + compatibility guarantees or semantic versioning. It is not suitable for production usage. + + Do not use it. + You have been warned. + """ + + def __init__(self): + """ + Initializes a new ChangeSetBuilder. + """ + self.intent = None + self.changes = [] + + @staticmethod + def no_changes() -> "ChangeSet": + """ + Represents an intent that the current data is up-to-date and doesn't + require changes. + """ + return ChangeSet( + intent_code=IntentCode.TRANSFER_NONE, selector=None, changes=[] + ) + + @staticmethod + def empty(selector) -> "ChangeSet": + """ + Returns an empty ChangeSet, which is useful for initializing a client + without data or for clearing out all existing data. + """ + return ChangeSet( + intent_code=IntentCode.TRANSFER_FULL, selector=selector, changes=[] + ) + + def start(self, intent: IntentCode): + """ + Begins a new change set with a given intent. + """ + self.intent = intent + self.changes = [] + + def expect_changes(self): + """ + Ensures that the current ChangeSetBuilder is prepared to handle changes. + + If a data source's initial connection reflects an updated status, we + need to keep the provided server intent. This allows subsequent changes + to come down the line without an explicit server intent. + + However, to maintain logical consistency, we need to ensure that the intent + is set to IntentTransferChanges. + """ + if self.intent is None: + raise ValueError("changeset: cannot expect changes without a server-intent") + + if self.intent != IntentCode.TRANSFER_NONE: + return + + self.intent = IntentCode.TRANSFER_CHANGES + + def reset(self): + """ + Clears any existing changes while preserving the current intent. + """ + self.changes = [] + + def finish(self, selector) -> ChangeSet: + """ + Identifies a changeset with a selector and returns the completed + changeset. Clears any existing changes while preserving the current + intent, so the builder can be reused. + """ + if self.intent is None: + raise ValueError("changeset: cannot complete without a server-intent") + + changeset = ChangeSet( + intent_code=self.intent, selector=selector, changes=self.changes + ) + self.changes = [] + + # Once a full transfer has been processed, all future changes should be + # assumed to be changes. Flag delivery can override this behavior by + # sending a new server intent to any connected stream. + if self.intent == IntentCode.TRANSFER_FULL: + self.intent = IntentCode.TRANSFER_CHANGES + + return changeset + + def add_put(self, kind, key, version, obj): + """ + Adds a new object to the changeset. + """ + self.changes.append( + Change( + action=ChangeType.PUT, kind=kind, key=key, version=version, object=obj + ) + ) + + def add_delete(self, kind, key, version): + """ + Adds a deletion to the changeset. + """ + self.changes.append( + Change(action=ChangeType.DELETE, kind=kind, key=key, version=version) + ) + + +@dataclass(frozen=True) +class Payload: + """ + Payload represents a payload delivered in a streaming response. + + This type is not stable, and not subject to any backwards + compatibility guarantees or semantic versioning. It is not suitable for production usage. + + Do not use it. + You have been warned. + """ + + id: str + target: int + code: IntentCode + reason: str + + def to_dict(self) -> dict: + """ + Serializes the Payload to a JSON-compatible dictionary. + """ + return { + "id": self.id, + "target": self.target, + "intentCode": self.code.value, + "reason": self.reason, + } + + @staticmethod + def from_dict(data: dict) -> "Payload": + """ + Create a Payload from a dictionary representation. + """ + intent_code = data.get("intentCode") + + if intent_code is None or not isinstance(intent_code, str): + raise ValueError( + "Invalid data for Payload: 'intentCode' key is missing or not a string" + ) + + return Payload( + id=data.get("id", ""), + target=data.get("target", 0), + code=IntentCode(intent_code), + reason=data.get("reason", ""), + ) + + +@dataclass(frozen=True) +class ServerIntent: + """ + ServerIntent represents the type of change associated with the payload + (e.g., transfer full, transfer changes, etc.) + + This type is not stable, and not subject to any backwards + compatibility guarantees or semantic versioning. It is not suitable for production usage. + + Do not use it. + You have been warned. + """ + + payload: Payload + + def to_dict(self) -> dict: + """ + Serializes the ServerIntent to a JSON-compatible dictionary. + """ + return { + "payloads": [self.payload.to_dict()], + } + + @staticmethod + def from_dict(data: dict) -> "ServerIntent": + """ + Create a ServerIntent from a dictionary representation. + """ + if "payloads" not in data or not isinstance(data["payloads"], list): + raise ValueError( + "Invalid data for ServerIntent: 'payloads' key is missing or not a list" + ) + if len(data["payloads"]) != 1: + raise ValueError( + "Invalid data for ServerIntent: expected exactly one payload" + ) + + payload = data["payloads"][0] + if not isinstance(payload, dict): + raise ValueError("Invalid payload in ServerIntent: expected a dictionary") + + return ServerIntent(payload=Payload.from_dict(payload)) + + +class SelectorStore(Protocol): + """ + SelectorStore represents a component capable of providing Selectors + for data retrieval. + + This type is not stable, and not subject to any backwards + compatibility guarantees or semantic versioning. It is not suitable for production usage. + + Do not use it. + You have been warned. + """ + + @abstractmethod + def selector(self) -> Selector: + """ + get_selector should return a Selector object that defines the criteria + for data retrieval. + """ + raise NotImplementedError + + +BasisResult = _Result[Basis, str] + + +class Initializer(Protocol): # pylint: disable=too-few-public-methods + """ + Initializer represents a component capable of retrieving a single data + result, such as from the LD polling API. + + The intent of initializers is to quickly fetch an initial set of data, + which may be stale but is fast to retrieve. This initial data serves as a + foundation for a Synchronizer to build upon, enabling it to provide updates + as new changes occur. + + This type is not stable, and not subject to any backwards + compatibility guarantees or semantic versioning. It is not suitable for production usage. + + Do not use it. + You have been warned. + """ + + @property + @abstractmethod + def name(self) -> str: + """ + Returns the name of the initializer, which is used for logging and debugging. + """ + raise NotImplementedError + + @abstractmethod + def fetch(self, ss: SelectorStore) -> BasisResult: + """ + fetch should retrieve the initial data set for the data source, returning + a Basis object on success, or an error message on failure. + + :param ss: A SelectorStore that provides the Selector to use as a basis for data retrieval. + """ + raise NotImplementedError + + +@dataclass(frozen=True) +class Update: + """ + Update represents the results of a synchronizer's ongoing sync + method. + + This type is not stable, and not subject to any backwards + compatibility guarantees or semantic versioning. It is not suitable for production usage. + + Do not use it. + You have been warned. + """ + + state: DataSourceState + change_set: Optional[ChangeSet] = None + error: Optional[DataSourceErrorInfo] = None + revert_to_fdv1: bool = False + environment_id: Optional[str] = None + + +class Synchronizer(Protocol): # pylint: disable=too-few-public-methods + """ + Synchronizer represents a component capable of synchronizing data from an external + data source, such as a streaming or polling API. + + It is responsible for yielding Update objects that represent the current state + of the data source, including any changes that have occurred since the last + synchronization. + + This type is not stable, and not subject to any backwards + compatibility guarantees or semantic versioning. It is not suitable for production usage. + + Do not use it. + You have been warned. + """ + @property + @abstractmethod + def name(self) -> str: + """ + Returns the name of the synchronizer, which is used for logging and debugging. + """ + raise NotImplementedError + + @abstractmethod + def sync(self, ss: SelectorStore) -> Generator[Update, None, None]: + """ + sync should begin the synchronization process for the data source, yielding + Update objects until the connection is closed or an unrecoverable error + occurs. + + :param ss: A SelectorStore that provides the Selector to use as a basis for data retrieval. + """ + raise NotImplementedError + + @abstractmethod + def stop(self): + """ + stop should halt the synchronization process, causing the sync method + to exit as soon as possible. + """ + raise NotImplementedError diff --git a/ldclient/testing/impl/datasourcev2/test_polling_initializer.py b/ldclient/testing/impl/datasourcev2/test_polling_initializer.py index 5e5e084f..bf152021 100644 --- a/ldclient/testing/impl/datasourcev2/test_polling_initializer.py +++ b/ldclient/testing/impl/datasourcev2/test_polling_initializer.py @@ -9,8 +9,8 @@ Selector, polling_payload_to_changeset ) -from ldclient.impl.datasystem.protocolv2 import ChangeSetBuilder, IntentCode from ldclient.impl.util import UnsuccessfulResponseException, _Fail, _Success +from ldclient.interfaces import ChangeSetBuilder, IntentCode from ldclient.testing.mock_components import MockSelectorStore diff --git a/ldclient/testing/impl/datasourcev2/test_polling_payload_parsing.py b/ldclient/testing/impl/datasourcev2/test_polling_payload_parsing.py index 2b483e47..580454f5 100644 --- a/ldclient/testing/impl/datasourcev2/test_polling_payload_parsing.py +++ b/ldclient/testing/impl/datasourcev2/test_polling_payload_parsing.py @@ -5,8 +5,8 @@ fdv1_polling_payload_to_changeset, polling_payload_to_changeset ) -from ldclient.impl.datasystem.protocolv2 import ChangeType, ObjectKind from ldclient.impl.util import _Fail, _Success +from ldclient.interfaces import ChangeType, ObjectKind def test_payload_is_missing_events_key(): diff --git a/ldclient/testing/impl/datasourcev2/test_polling_synchronizer.py b/ldclient/testing/impl/datasourcev2/test_polling_synchronizer.py index 7aa3686e..ebb2674a 100644 --- a/ldclient/testing/impl/datasourcev2/test_polling_synchronizer.py +++ b/ldclient/testing/impl/datasourcev2/test_polling_synchronizer.py @@ -4,21 +4,13 @@ import pytest from ld_eventsource.sse_client import Event -from ldclient.impl.datasourcev2 import PollingResult -from ldclient.impl.datasourcev2.polling import PollingDataSource +from ldclient.impl.datasourcev2.polling import PollingDataSource, PollingResult from ldclient.impl.datasystem.protocolv2 import ( - ChangeSetBuilder, - ChangeType, DeleteObject, Error, EventName, Goodbye, - IntentCode, - ObjectKind, - Payload, - PutObject, - Selector, - ServerIntent + PutObject ) from ldclient.impl.util import ( _LD_ENVID_HEADER, @@ -27,7 +19,17 @@ _Fail, _Success ) -from ldclient.interfaces import DataSourceErrorKind, DataSourceState +from ldclient.interfaces import ( + ChangeSetBuilder, + ChangeType, + DataSourceErrorKind, + DataSourceState, + IntentCode, + ObjectKind, + Payload, + Selector, + ServerIntent +) from ldclient.testing.mock_components import MockSelectorStore diff --git a/ldclient/testing/impl/datasourcev2/test_streaming_synchronizer.py b/ldclient/testing/impl/datasourcev2/test_streaming_synchronizer.py index c581e785..b91d5fba 100644 --- a/ldclient/testing/impl/datasourcev2/test_streaming_synchronizer.py +++ b/ldclient/testing/impl/datasourcev2/test_streaming_synchronizer.py @@ -16,22 +16,25 @@ SseClientBuilder, StreamingDataSource ) -from ldclient.impl.datasystem import SelectorStore from ldclient.impl.datasystem.protocolv2 import ( - ChangeType, DeleteObject, Error, EventName, Goodbye, + PutObject +) +from ldclient.impl.util import _LD_ENVID_HEADER, _LD_FD_FALLBACK_HEADER +from ldclient.interfaces import ( + ChangeType, + DataSourceErrorKind, + DataSourceState, IntentCode, ObjectKind, Payload, - PutObject, Selector, + SelectorStore, ServerIntent ) -from ldclient.impl.util import _LD_ENVID_HEADER, _LD_FD_FALLBACK_HEADER -from ldclient.interfaces import DataSourceErrorKind, DataSourceState from ldclient.testing.mock_components import MockSelectorStore diff --git a/ldclient/testing/impl/datasystem/test_config.py b/ldclient/testing/impl/datasystem/test_config.py index a36c748d..c9f14c31 100644 --- a/ldclient/testing/impl/datasystem/test_config.py +++ b/ldclient/testing/impl/datasystem/test_config.py @@ -5,7 +5,7 @@ from ldclient.config import Config as LDConfig from ldclient.config import DataSystemConfig -from ldclient.impl.datasystem.config import ( +from ldclient.datasystem import ( ConfigBuilder, custom, default, diff --git a/ldclient/testing/impl/datasystem/test_fdv2_datasystem.py b/ldclient/testing/impl/datasystem/test_fdv2_datasystem.py index dd9a3e97..c49b7137 100644 --- a/ldclient/testing/impl/datasystem/test_fdv2_datasystem.py +++ b/ldclient/testing/impl/datasystem/test_fdv2_datasystem.py @@ -6,10 +6,16 @@ from mock import Mock from ldclient.config import Config, DataSystemConfig -from ldclient.impl.datasystem import DataAvailability, Synchronizer +from ldclient.impl.datasystem import DataAvailability from ldclient.impl.datasystem.fdv2 import FDv2 from ldclient.integrations.test_datav2 import TestDataV2 -from ldclient.interfaces import DataSourceState, DataSourceStatus, FlagChange +from ldclient.interfaces import ( + DataSourceState, + DataSourceStatus, + FlagChange, + Synchronizer, + Update +) from ldclient.versioned_data_kind import FEATURES @@ -52,7 +58,7 @@ def listener(flag_change: FlagChange): assert set_on_ready.wait(1), "Data system did not become ready in time" assert initialized.wait(1), "Flag change listener was not called in time" - td_synchronizer.update(td_synchronizer.flag("feature-flag").on(False)) + td_synchronizer.update(td_synchronizer.flag("feature-flag").on(True)) assert modified.wait(1), "Flag change listener was not called in time" assert len(changes) == 3 assert changes[0].key == "feature-flag" @@ -180,7 +186,6 @@ def test_fdv2_falls_back_to_fdv1_on_polling_error_with_header(): mock_primary.stop = Mock() # Simulate a synchronizer that yields an OFF state with revert_to_fdv1=True - from ldclient.impl.datasystem import Update mock_primary.sync.return_value = iter([ Update( state=DataSourceState.OFF, @@ -231,7 +236,6 @@ def test_fdv2_falls_back_to_fdv1_on_polling_success_with_header(): mock_primary.name = "mock-primary" mock_primary.stop = Mock() - from ldclient.impl.datasystem import Update mock_primary.sync.return_value = iter([ Update( state=DataSourceState.VALID, @@ -290,7 +294,6 @@ def test_fdv2_falls_back_to_fdv1_with_initializer(): mock_primary.name = "mock-primary" mock_primary.stop = Mock() - from ldclient.impl.datasystem import Update mock_primary.sync.return_value = iter([ Update( state=DataSourceState.OFF, @@ -340,7 +343,6 @@ def test_fdv2_no_fallback_without_header(): mock_primary.name = "mock-primary" mock_primary.stop = Mock() - from ldclient.impl.datasystem import Update mock_primary.sync.return_value = iter([ Update( state=DataSourceState.INTERRUPTED, @@ -396,7 +398,6 @@ def test_fdv2_stays_on_fdv1_after_fallback(): mock_primary.name = "mock-primary" mock_primary.stop = Mock() - from ldclient.impl.datasystem import Update mock_primary.sync.return_value = iter([ Update( state=DataSourceState.OFF, diff --git a/ldclient/testing/impl/datasystem/test_fdv2_persistence.py b/ldclient/testing/impl/datasystem/test_fdv2_persistence.py index 7f77da17..a59fc772 100644 --- a/ldclient/testing/impl/datasystem/test_fdv2_persistence.py +++ b/ldclient/testing/impl/datasystem/test_fdv2_persistence.py @@ -363,15 +363,15 @@ def test_persistent_store_delete_operations(): """Test that delete operations are written to persistent store in READ_WRITE mode""" # We'll need to manually trigger a delete via the store # This is more of an integration test with the Store class - from ldclient.impl.datasystem.protocolv2 import ( + from ldclient.impl.datasystem.store import Store + from ldclient.impl.listeners import Listeners + from ldclient.interfaces import ( Change, ChangeSet, ChangeType, IntentCode, ObjectKind ) - from ldclient.impl.datasystem.store import Store - from ldclient.impl.listeners import Listeners # Pre-populate with a flag initial_data = { @@ -664,15 +664,15 @@ def test_persistent_store_outage_recovery_no_flush_when_unavailable(): def test_persistent_store_commit_encodes_data_correctly(): """Test that Store.commit() properly encodes data before writing to persistent store""" - from ldclient.impl.datasystem.protocolv2 import ( + from ldclient.impl.datasystem.store import Store + from ldclient.impl.listeners import Listeners + from ldclient.interfaces import ( Change, ChangeSet, ChangeType, IntentCode, ObjectKind ) - from ldclient.impl.datasystem.store import Store - from ldclient.impl.listeners import Listeners persistent_store = StubFeatureStore() store = Store(Listeners(), Listeners()) @@ -739,15 +739,15 @@ def test_persistent_store_commit_with_no_persistent_store(): def test_persistent_store_commit_handles_errors(): """Test that Store.commit() handles errors from persistent store gracefully""" - from ldclient.impl.datasystem.protocolv2 import ( + from ldclient.impl.datasystem.store import Store + from ldclient.impl.listeners import Listeners + from ldclient.interfaces import ( Change, ChangeSet, ChangeType, IntentCode, ObjectKind ) - from ldclient.impl.datasystem.store import Store - from ldclient.impl.listeners import Listeners class FailingFeatureStore(StubFeatureStore): """A feature store that always fails on init""" diff --git a/ldclient/testing/integrations/test_file_data_sourcev2.py b/ldclient/testing/integrations/test_file_data_sourcev2.py index 35bd8381..c588ad47 100644 --- a/ldclient/testing/integrations/test_file_data_sourcev2.py +++ b/ldclient/testing/integrations/test_file_data_sourcev2.py @@ -7,14 +7,14 @@ import pytest from ldclient.config import Config -from ldclient.impl.datasystem.protocolv2 import ( +from ldclient.impl.util import _Fail, _Success +from ldclient.integrations import Files +from ldclient.interfaces import ( + DataSourceState, IntentCode, ObjectKind, Selector ) -from ldclient.impl.util import _Fail, _Success -from ldclient.integrations import Files -from ldclient.interfaces import DataSourceState from ldclient.testing.mock_components import MockSelectorStore # Skip all tests in this module in CI due to flakiness diff --git a/ldclient/testing/integrations/test_test_data_sourcev2.py b/ldclient/testing/integrations/test_test_data_sourcev2.py index e0ff825d..177a6af5 100644 --- a/ldclient/testing/integrations/test_test_data_sourcev2.py +++ b/ldclient/testing/integrations/test_test_data_sourcev2.py @@ -5,15 +5,15 @@ import pytest from ldclient.config import Config -from ldclient.impl.datasystem.protocolv2 import ( +from ldclient.impl.util import _Fail, _Success +from ldclient.integrations.test_datav2 import FlagBuilderV2, TestDataV2 +from ldclient.interfaces import ( ChangeType, + DataSourceState, IntentCode, ObjectKind, Selector ) -from ldclient.impl.util import _Fail, _Success -from ldclient.integrations.test_datav2 import FlagBuilderV2, TestDataV2 -from ldclient.interfaces import DataSourceState from ldclient.testing.mock_components import MockSelectorStore # Test Data + Data Source V2 diff --git a/ldclient/testing/mock_components.py b/ldclient/testing/mock_components.py index f1b20235..ad93b32b 100644 --- a/ldclient/testing/mock_components.py +++ b/ldclient/testing/mock_components.py @@ -1,8 +1,11 @@ import time from typing import Callable -from ldclient.impl.datasystem.protocolv2 import Selector -from ldclient.interfaces import BigSegmentStore, BigSegmentStoreMetadata +from ldclient.interfaces import ( + BigSegmentStore, + BigSegmentStoreMetadata, + Selector +) class MockBigSegmentStore(BigSegmentStore):