From e5b121f92a09fcaf717f88b426348798961ab62e Mon Sep 17 00:00:00 2001 From: Steven Zhang Date: Tue, 25 Nov 2025 08:58:22 -0700 Subject: [PATCH 1/3] feat: adding data system option to create file datasource intializer --- ldclient/datasystem.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/ldclient/datasystem.py b/ldclient/datasystem.py index 89a15e1..8696afa 100644 --- a/ldclient/datasystem.py +++ b/ldclient/datasystem.py @@ -16,6 +16,9 @@ StreamingDataSource, StreamingDataSourceBuilder ) +from ldclient.impl.integrations.files.file_data_sourcev2 import ( + _FileDataSourceV2 +) from ldclient.interfaces import ( DataStoreMode, FeatureStore, @@ -125,6 +128,12 @@ def builder(config: LDConfig) -> StreamingDataSource: return builder +def file_ds_builder(paths: List[str]) -> Builder[Initializer]: + def builder(_: LDConfig) -> Initializer: + return _FileDataSourceV2(paths) + + return builder + def default() -> ConfigBuilder: """ Default is LaunchDarkly's recommended flag data acquisition strategy. From 064f65c76135c385a6fdcb0209caa0e7a8cf872c Mon Sep 17 00:00:00 2001 From: Steven Zhang Date: Tue, 25 Nov 2025 11:02:52 -0700 Subject: [PATCH 2/3] fix: modified initializer behavior to spec This commit will make it so that the client will only report initalized when a valid selector is present in the basis --- ldclient/datasystem.py | 1 + ldclient/impl/datasystem/fdv2.py | 6 +- .../impl/datasystem/test_fdv2_datasystem.py | 112 ++++++++++++++++++ 3 files changed, 117 insertions(+), 2 deletions(-) diff --git a/ldclient/datasystem.py b/ldclient/datasystem.py index 8696afa..085ecdd 100644 --- a/ldclient/datasystem.py +++ b/ldclient/datasystem.py @@ -134,6 +134,7 @@ def builder(_: LDConfig) -> Initializer: return builder + def default() -> ConfigBuilder: """ Default is LaunchDarkly's recommended flag data acquisition strategy. diff --git a/ldclient/impl/datasystem/fdv2.py b/ldclient/impl/datasystem/fdv2.py index 21f95c0..86ac046 100644 --- a/ldclient/impl/datasystem/fdv2.py +++ b/ldclient/impl/datasystem/fdv2.py @@ -409,9 +409,11 @@ def _run_initializers(self, set_on_ready: Event): # 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 ready event if an only if a selector is defined for the changeset + selector_is_defined = basis.change_set.selector is not None and basis.change_set.selector.is_defined() + if selector_is_defined and not set_on_ready.is_set(): set_on_ready.set() + return except Exception as e: log.error("Initializer failed with exception: %s", e) diff --git a/ldclient/testing/impl/datasystem/test_fdv2_datasystem.py b/ldclient/testing/impl/datasystem/test_fdv2_datasystem.py index c49b713..09ff4c5 100644 --- a/ldclient/testing/impl/datasystem/test_fdv2_datasystem.py +++ b/ldclient/testing/impl/datasystem/test_fdv2_datasystem.py @@ -1,11 +1,14 @@ # pylint: disable=missing-docstring +import os +import tempfile from threading import Event from typing import List from mock import Mock from ldclient.config import Config, DataSystemConfig +from ldclient.datasystem import file_ds_builder from ldclient.impl.datasystem import DataAvailability from ldclient.impl.datasystem.fdv2 import FDv2 from ldclient.integrations.test_datav2 import TestDataV2 @@ -432,3 +435,112 @@ def test_fdv2_stays_on_fdv1_after_fallback(): store = fdv2.store flag = store.get(FEATURES, "fdv1-flag", lambda x: x) assert flag is not None + + +def test_fdv2_with_file_to_polling_initializers(): + """ + Test that FDv2 can be initialized with a file data source and a polling data source. + In this case the results from the file data source should be overwritten by the + results from the polling datasource. + """ + initial_flag_data = ''' +{ + "flags": { + "feature-flag": { + "key": "feature-flag", + "version": 0, + "on": false, + "fallthrough": { + "variation": 0 + }, + "variations": ["off", "on"] + } + } +} +''' + f, path = tempfile.mkstemp(suffix='.json') + try: + os.write(f, initial_flag_data.encode("utf-8")) + os.close(f) + + td_initializer = TestDataV2.data_source() + td_initializer.update(td_initializer.flag("feature-flag").on(True)) + + # We actually do not care what this synchronizer does. + td_synchronizer = TestDataV2.data_source() + + data_system_config = DataSystemConfig( + initializers=[file_ds_builder([path]), td_initializer.build_initializer], + primary_synchronizer=td_synchronizer.build_synchronizer, + ) + + set_on_ready = Event() + fdv2 = FDv2(Config(sdk_key="dummy"), data_system_config) + count = 0 + + def listener(_: FlagChange): + nonlocal count + count += 1 + + 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 count == 2, "Invalid initializer process" + fdv2.stop() + finally: + os.remove(path) + + +def test_fdv2_with_polling_to_file_initializers(): + """ + Test that when FDv2 is initialized with a polling datasource and a file datasource + then only the polling processor needs to run. + """ + initial_flag_data = ''' +{ + "flags": { + "feature-flag": { + "key": "feature-flag", + "version": 0, + "on": false, + "fallthrough": { + "variation": 0 + }, + "variations": ["off", "on"] + } + } +} +''' + f, path = tempfile.mkstemp(suffix='.json') + try: + os.write(f, initial_flag_data.encode("utf-8")) + os.close(f) + + td_initializer = TestDataV2.data_source() + td_initializer.update(td_initializer.flag("feature-flag").on(True)) + + # We actually do not care what this synchronizer does. + td_synchronizer = TestDataV2.data_source() + + data_system_config = DataSystemConfig( + initializers=[td_initializer.build_initializer, file_ds_builder([path])], + primary_synchronizer=td_synchronizer.build_synchronizer, + ) + + set_on_ready = Event() + fdv2 = FDv2(Config(sdk_key="dummy"), data_system_config) + count = 0 + + def listener(_: FlagChange): + nonlocal count + count += 1 + + 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 count == 1, "Invalid initializer process" + fdv2.stop() + finally: + os.remove(path) From 04a2c538e5d0f1f87f35feaaa949ae55d3cb8716 Mon Sep 17 00:00:00 2001 From: Steven Zhang Date: Tue, 25 Nov 2025 16:45:06 -0700 Subject: [PATCH 3/3] chore: PR comments - modified the tests to be more robust - modified wording on test to be more clear on what is being tested - removed unnecessary check for ready event --- ldclient/impl/datasystem/fdv2.py | 3 +-- .../impl/datasystem/test_fdv2_datasystem.py | 21 ++++++++++--------- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/ldclient/impl/datasystem/fdv2.py b/ldclient/impl/datasystem/fdv2.py index 86ac046..d411fd5 100644 --- a/ldclient/impl/datasystem/fdv2.py +++ b/ldclient/impl/datasystem/fdv2.py @@ -410,8 +410,7 @@ def _run_initializers(self, set_on_ready: Event): self._store.apply(basis.change_set, basis.persist) # Set ready event if an only if a selector is defined for the changeset - selector_is_defined = basis.change_set.selector is not None and basis.change_set.selector.is_defined() - if selector_is_defined and not set_on_ready.is_set(): + if basis.change_set.selector is not None and basis.change_set.selector.is_defined(): set_on_ready.set() return except Exception as e: diff --git a/ldclient/testing/impl/datasystem/test_fdv2_datasystem.py b/ldclient/testing/impl/datasystem/test_fdv2_datasystem.py index 09ff4c5..c77f799 100644 --- a/ldclient/testing/impl/datasystem/test_fdv2_datasystem.py +++ b/ldclient/testing/impl/datasystem/test_fdv2_datasystem.py @@ -437,11 +437,10 @@ def test_fdv2_stays_on_fdv1_after_fallback(): assert flag is not None -def test_fdv2_with_file_to_polling_initializers(): +def test_fdv2_initializer_should_run_until_success(): """ - Test that FDv2 can be initialized with a file data source and a polling data source. - In this case the results from the file data source should be overwritten by the - results from the polling datasource. + Test that FDv2 initializers will run in order until a successful run. Then + the datasystem is expected to transition to run synchronizers. """ initial_flag_data = ''' { @@ -475,27 +474,29 @@ def test_fdv2_with_file_to_polling_initializers(): ) set_on_ready = Event() + synchronizer_ran = Event() fdv2 = FDv2(Config(sdk_key="dummy"), data_system_config) count = 0 def listener(_: FlagChange): nonlocal count count += 1 + if count == 3: + synchronizer_ran.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 count == 2, "Invalid initializer process" - fdv2.stop() + assert synchronizer_ran.wait(1), "Data system did not transition to synchronizer" finally: os.remove(path) -def test_fdv2_with_polling_to_file_initializers(): +def test_fdv2_should_finish_initialization_on_first_successful_initializer(): """ - Test that when FDv2 is initialized with a polling datasource and a file datasource - then only the polling processor needs to run. + Test that when a FDv2 initializer returns a basis and selector that the rest + of the intializers will be skipped and the client starts synchronizing phase. """ initial_flag_data = ''' { @@ -525,7 +526,7 @@ def test_fdv2_with_polling_to_file_initializers(): data_system_config = DataSystemConfig( initializers=[td_initializer.build_initializer, file_ds_builder([path])], - primary_synchronizer=td_synchronizer.build_synchronizer, + primary_synchronizer=None, ) set_on_ready = Event()