Skip to content

Commit 0fc7dac

Browse files
committed
feat: Make the flag tracker available before start()
Build the flag tracker in __init__ instead of start(), matching every other LaunchDarkly SDK, so flag_tracker no longer raises before start(). The client owns the flag-change Listeners and injects it into the data system, so listeners registered before start() fire once data flows. The callback scheduler is created lazily on the first add_flag_value_change_listener call, which always runs on the loop thread.
1 parent 378f27c commit 0fc7dac

4 files changed

Lines changed: 41 additions & 20 deletions

File tree

ldclient/async_client.py

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
create_diagnostic_id
3838
)
3939
from ldclient.impl.events.types import EventFactory
40+
from ldclient.impl.listeners import Listeners
4041
from ldclient.impl.model.feature_flag import FeatureFlag
4142
from ldclient.impl.stubs import AsyncNullEventProcessor
4243
from ldclient.impl.util import log
@@ -105,6 +106,16 @@ def __init__(self, config: AsyncConfig):
105106
self._event_factory_default = EventFactory(False)
106107
self._event_factory_with_reasons = EventFactory(True)
107108

109+
self._flag_change_listeners = Listeners()
110+
111+
async def variation_eval_fn(key, context):
112+
return await self.variation(key, context, None)
113+
114+
self.__flag_tracker = AsyncFlagTrackerImpl(
115+
self._flag_change_listeners,
116+
variation_eval_fn
117+
)
118+
108119
async def start(self, start_wait: float = 5.0) -> None:
109120
"""Start the client: create the HTTP session, data system, and event processor.
110121
@@ -200,13 +211,6 @@ async def __start_up(self, start_wait: float):
200211
self._session = await self._create_http_session()
201212
self._data_system = self._make_data_system()
202213

203-
async def variation_eval_fn(key, context):
204-
return await self.variation(key, context, None)
205-
206-
self.__flag_tracker = AsyncFlagTrackerImpl(
207-
self._data_system.flag_change_listeners,
208-
variation_eval_fn
209-
)
210214
# Expose providers and store from data system
211215
self.__data_store_status_provider = self._data_system.data_store_status_provider
212216
self.__data_source_status_provider = (
@@ -289,7 +293,7 @@ def _make_data_system(self) -> AsyncDataSystem:
289293
if datasystem_config is None:
290294
from ldclient.impl.datasystem.async_fdv1 import AsyncFDv1
291295

292-
return AsyncFDv1(self._config, self._select_feature_store(), self._session, self._proxy)
296+
return AsyncFDv1(self._config, self._select_feature_store(), self._flag_change_listeners, self._session, self._proxy)
293297

294298
raise NotImplementedError("FDv2 is not yet supported in the async client")
295299

@@ -747,9 +751,10 @@ def flag_tracker(self) -> AsyncFlagTracker:
747751
The :class:`ldclient.interfaces.AsyncFlagTracker` contains methods for
748752
requesting notifications about feature flag changes using an event
749753
listener model.
754+
755+
Listeners registered before ``start()`` receive change events once the
756+
data system starts.
750757
"""
751-
if not self._started:
752-
raise RuntimeError("AsyncLDClient.flag_tracker is not available until after start()")
753758
return self.__flag_tracker
754759

755760
async def __aenter__(self):

ldclient/impl/async_flag_tracker.py

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from typing import Any, Callable
1+
from typing import Any, Callable, Optional
22

33
from ldclient.context import Context
44
from ldclient.impl.aio.concurrency import AsyncCallbackScheduler, AsyncLock
@@ -45,7 +45,14 @@ class AsyncFlagTrackerImpl(AsyncFlagTracker):
4545
def __init__(self, listeners: Listeners, eval_fn: Callable):
4646
self.__listeners = listeners
4747
self.__eval_fn = eval_fn
48-
self.__scheduler = AsyncCallbackScheduler()
48+
self.__scheduler: Optional[AsyncCallbackScheduler] = None
49+
50+
def _get_scheduler(self) -> AsyncCallbackScheduler:
51+
"""Creates the callback scheduler on first use. Called only from async
52+
methods, so a running loop always exists for it to capture."""
53+
if self.__scheduler is None:
54+
self.__scheduler = AsyncCallbackScheduler()
55+
return self.__scheduler
4956

5057
def add_listener(self, listener: Callable[[FlagChange], None]):
5158
self.__listeners.add(listener)
@@ -54,7 +61,7 @@ def remove_listener(self, listener: Callable[[FlagChange], None]):
5461
self.__listeners.remove(listener)
5562

5663
async def add_flag_value_change_listener(self, key: str, context: Context, fn: Callable[[FlagValueChange], None]) -> Callable[[FlagChange], None]:
57-
listener = await AsyncFlagValueChangeListener.create(key, context, fn, self.__eval_fn, self.__scheduler)
64+
listener = await AsyncFlagValueChangeListener.create(key, context, fn, self.__eval_fn, self._get_scheduler())
5865
self.add_listener(listener)
5966

6067
return listener

ldclient/impl/datasystem/async_fdv1.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ class AsyncFDv1(AsyncDataSystem):
4242
monitoring.
4343
"""
4444

45-
def __init__(self, config: AsyncConfig, store: AsyncFeatureStore, session: Optional[Any] = None, proxy: Optional[str] = None):
45+
def __init__(self, config: AsyncConfig, store: AsyncFeatureStore, flag_change_listeners: Listeners, session: Optional[Any] = None, proxy: Optional[str] = None):
4646
self._config = config
4747
self._store = store
4848
self._session = session
@@ -59,9 +59,11 @@ def __init__(self, config: AsyncConfig, store: AsyncFeatureStore, session: Optio
5959
self._store, self._data_store_update_sink # type: ignore[arg-type]
6060
)
6161

62-
# Set up the data source status tracking and listeners
62+
# Set up the data source status tracking and listeners. The flag-change
63+
# Listeners is provided by the client so its flag tracker (built before
64+
# start()) shares the same collection.
6365
self._data_source_listeners = Listeners()
64-
self._flag_change_listeners = Listeners()
66+
self._flag_change_listeners = flag_change_listeners
6567
self._data_source_update_sink = AsyncDataSourceUpdateSinkImpl(
6668
self._store,
6769
self._data_source_listeners,

ldclient/testing/test_async_client.py

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -222,11 +222,18 @@ def after_evaluation(self, series_context, data, detail):
222222

223223

224224
@pytest.mark.asyncio
225-
async def test_flag_tracker_before_start_raises():
226-
"""Accessing flag_tracker before start() raises RuntimeError."""
225+
async def test_flag_tracker_available_before_start():
226+
"""flag_tracker is available before start(); the client's flag-change
227+
Listeners is the same collection the data system uses after start()."""
227228
client = AsyncLDClient(_offline_config())
228-
with pytest.raises(RuntimeError):
229-
_ = client.flag_tracker
229+
230+
tracker = client.flag_tracker
231+
assert tracker is not None
232+
tracker.add_listener(lambda change: None)
233+
234+
await client.start()
235+
assert client._data_system.flag_change_listeners is client._flag_change_listeners
236+
await client.close()
230237

231238

232239
@pytest.mark.asyncio

0 commit comments

Comments
 (0)