Skip to content

Commit 3e9be62

Browse files
committed
refactor: Build client components in __init__ and create the session lazily
Construct the data system, status providers, big-segment manager, evaluator, and flag tracker in __init__ (loop-free); start() only does the loop-bound work. The shared aiohttp session is created lazily on first use inside the loop (via a provider the data source resolves), so offline/LDD mode creates none. The big-segment manager's poll task is created in __init__ and started in start(). This matches the loop-free-init / loop-bound-start shape used across the async ecosystem, and makes the status providers available before start() too.
1 parent 0fc7dac commit 3e9be62

5 files changed

Lines changed: 66 additions & 66 deletions

File tree

ldclient/async_client.py

Lines changed: 43 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,6 @@
3737
create_diagnostic_id
3838
)
3939
from ldclient.impl.events.types import EventFactory
40-
from ldclient.impl.listeners import Listeners
4140
from ldclient.impl.model.feature_flag import FeatureFlag
4241
from ldclient.impl.stubs import AsyncNullEventProcessor
4342
from ldclient.impl.util import log
@@ -53,18 +52,6 @@
5352
from ldclient.versioned_data_kind import FEATURES, SEGMENTS
5453

5554

56-
class _NotStartedDataSystem:
57-
"""Placeholder data system used before start(); reports that only
58-
application-provided defaults are available."""
59-
60-
@property
61-
def data_availability(self) -> DataAvailability:
62-
return DataAvailability.DEFAULTS
63-
64-
async def stop(self) -> None:
65-
pass
66-
67-
6855
class AsyncLDClient:
6956
"""Async LaunchDarkly SDK client.
7057
@@ -96,23 +83,47 @@ def __init__(self, config: AsyncConfig):
9683

9784
self._session = None
9885
self._proxy: Optional[str] = None
99-
# Pre-start placeholders so that evaluation/track/identify before
100-
# start() degrade gracefully (defaults returned, events dropped).
86+
# Event processor is a no-op until start(); track/identify before start()
87+
# drop events.
10188
self._event_processor: Any = AsyncNullEventProcessor()
102-
self._data_system: AsyncDataSystem = _NotStartedDataSystem() # type: ignore[assignment]
10389

10490
self.__hooks: List = list(config.hooks)
10591

10692
self._event_factory_default = EventFactory(False)
10793
self._event_factory_with_reasons = EventFactory(True)
10894

109-
self._flag_change_listeners = Listeners()
95+
# Build the object graph here (loop-free). start() supplies the loop-bound
96+
# resources: the HTTP session (created lazily), the data source, the
97+
# big-segment poll, and the event processor. Evaluation before start()
98+
# serves whatever the store already has.
99+
self._data_system: AsyncDataSystem = self._make_data_system()
100+
101+
self.__data_store_status_provider = self._data_system.data_store_status_provider
102+
self.__data_source_status_provider = self._data_system.data_source_status_provider
103+
104+
self.__big_segment_store_manager = AsyncBigSegmentStoreManager(self._config.big_segments)
105+
106+
async def get_flag_fn(key):
107+
return await self._data_system.store.get(FEATURES, key)
108+
109+
async def get_segment_fn(key):
110+
return await self._data_system.store.get(SEGMENTS, key)
111+
112+
async def get_membership_fn(key):
113+
return await self.__big_segment_store_manager.get_user_membership(key)
114+
115+
self._evaluator = AsyncEvaluator(
116+
get_flag_fn,
117+
get_segment_fn,
118+
get_membership_fn,
119+
log,
120+
)
110121

111122
async def variation_eval_fn(key, context):
112123
return await self.variation(key, context, None)
113124

114125
self.__flag_tracker = AsyncFlagTrackerImpl(
115-
self._flag_change_listeners,
126+
self._data_system.flag_change_listeners,
116127
variation_eval_fn
117128
)
118129

@@ -208,33 +219,8 @@ async def __start_up(self, start_wait: float):
208219

209220
self.__hooks = self._config.hooks + plugin_hooks
210221

211-
self._session = await self._create_http_session()
212-
self._data_system = self._make_data_system()
213-
214-
# Expose providers and store from data system
215-
self.__data_store_status_provider = self._data_system.data_store_status_provider
216-
self.__data_source_status_provider = (
217-
self._data_system.data_source_status_provider
218-
)
219-
220-
big_segment_store_manager = AsyncBigSegmentStoreManager(self._config.big_segments)
221-
self.__big_segment_store_manager = big_segment_store_manager
222-
223-
async def get_flag_fn(key):
224-
return await self._data_system.store.get(FEATURES, key)
225-
226-
async def get_segment_fn(key):
227-
return await self._data_system.store.get(SEGMENTS, key)
228-
229-
async def get_membership_fn(key):
230-
return await big_segment_store_manager.get_user_membership(key)
231-
232-
self._evaluator = AsyncEvaluator(
233-
get_flag_fn,
234-
get_segment_fn,
235-
get_membership_fn,
236-
log,
237-
)
222+
# Start the big-segment status poll now that a loop is running.
223+
self.__big_segment_store_manager.start()
238224

239225
if self._config.offline:
240226
log.info("Started LaunchDarkly Client in offline mode")
@@ -265,8 +251,16 @@ async def get_membership_fn(key):
265251
else:
266252
log.warning("Initialization timeout exceeded for LaunchDarkly Client or an error occurred. " "Feature Flags may not yet be available.")
267253

268-
async def _create_http_session(self):
269-
"""Create and return the aiohttp session. Called from __start_up."""
254+
def _get_session(self):
255+
"""Return the shared aiohttp session, creating it on first use inside the
256+
event loop. Nothing creates it in offline/LDD mode, because no network
257+
component asks for it."""
258+
if self._session is None:
259+
self._session = self._create_http_session()
260+
return self._session
261+
262+
def _create_http_session(self):
263+
"""Create and return the aiohttp session."""
270264
import ssl
271265

272266
import aiohttp
@@ -293,7 +287,7 @@ def _make_data_system(self) -> AsyncDataSystem:
293287
if datasystem_config is None:
294288
from ldclient.impl.datasystem.async_fdv1 import AsyncFDv1
295289

296-
return AsyncFDv1(self._config, self._select_feature_store(), self._flag_change_listeners, self._session, self._proxy)
290+
return AsyncFDv1(self._config, self._select_feature_store(), self._get_session)
297291

298292
raise NotImplementedError("FDv2 is not yet supported in the async client")
299293

@@ -319,7 +313,7 @@ def _set_event_processor(self, config):
319313
if not config.event_processor_class:
320314
diagnostic_id = create_diagnostic_id(config)
321315
diagnostic_accumulator = None if config.diagnostic_opt_out else _DiagnosticAccumulator(diagnostic_id)
322-
self._event_processor = DefaultAsyncEventProcessor(config, self._session, diagnostic_accumulator=diagnostic_accumulator)
316+
self._event_processor = DefaultAsyncEventProcessor(config, self._get_session(), diagnostic_accumulator=diagnostic_accumulator)
323317
return diagnostic_accumulator
324318
self._event_processor = config.event_processor_class(config)
325319
return None

ldclient/impl/async_big_segments.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,10 +21,9 @@
2121
class AsyncBigSegmentStoreManager:
2222
"""
2323
Internal component that decorates the Big Segment store with caching behavior, and also polls the
24-
store to track its status. The constructor starts the polling task.
24+
store to track its status. Call start() to begin the status polling task.
2525
"""
2626

27-
# Because the constructor starts the polling task, it must run within a running event loop.
2827
def __init__(self, config: AsyncBigSegmentsConfig):
2928
self.__store = config.store
3029

@@ -36,6 +35,11 @@ def __init__(self, config: AsyncBigSegmentsConfig):
3635
if self.__store:
3736
self.__cache = ExpiringDict(max_len=config.context_cache_size, max_age_seconds=config.context_cache_time)
3837
self.__poll_task = AsyncRepeatingTask("ldclient.bigsegment.status-poll", config.status_poll_interval, 0, self.poll_store_and_update_status)
38+
39+
def start(self):
40+
"""Starts the status polling task. Separated from __init__ so the manager
41+
can be built without a running loop; the client calls this from start()."""
42+
if self.__poll_task is not None:
3943
self.__poll_task.start()
4044

4145
async def stop(self):

ldclient/impl/datasystem/async_fdv1.py

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

33
from ldclient.async_config import AsyncConfig
44
from ldclient.impl.aio.concurrency import AsyncEvent
@@ -42,11 +42,12 @@ class AsyncFDv1(AsyncDataSystem):
4242
monitoring.
4343
"""
4444

45-
def __init__(self, config: AsyncConfig, store: AsyncFeatureStore, flag_change_listeners: Listeners, session: Optional[Any] = None, proxy: Optional[str] = None):
45+
def __init__(self, config: AsyncConfig, store: AsyncFeatureStore, session_provider: Callable[[], Any]):
4646
self._config = config
4747
self._store = store
48-
self._session = session
49-
self._proxy = proxy
48+
# The client creates the aiohttp session lazily inside the loop; the data
49+
# source resolves it here when it builds its network processor at start().
50+
self._session_provider = session_provider
5051

5152
# Set up data store status tracking (no store wrapper)
5253
self._data_store_listeners = Listeners()
@@ -59,11 +60,9 @@ def __init__(self, config: AsyncConfig, store: AsyncFeatureStore, flag_change_li
5960
self._store, self._data_store_update_sink # type: ignore[arg-type]
6061
)
6162

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.
63+
# Set up the data source status tracking and listeners
6564
self._data_source_listeners = Listeners()
66-
self._flag_change_listeners = flag_change_listeners
65+
self._flag_change_listeners = Listeners()
6766
self._data_source_update_sink = AsyncDataSourceUpdateSinkImpl(
6867
self._store,
6968
self._data_source_listeners,
@@ -157,7 +156,7 @@ def _make_update_processor(self, config: AsyncConfig, store: AsyncFeatureStore,
157156
store,
158157
ready,
159158
self._diagnostic_accumulator,
160-
AsyncSSEFactory(config, session=self._session, proxy=self._proxy),
159+
AsyncSSEFactory(config, session=self._session_provider(), proxy=config.http.http_proxy),
161160
)
162161

163162
log.info("Disabling streaming API")
@@ -168,6 +167,6 @@ def _make_update_processor(self, config: AsyncConfig, store: AsyncFeatureStore,
168167
else:
169168
feature_requester = AsyncFeatureRequesterImpl(
170169
config,
171-
AsyncHTTPTransport(config, client=self._session),
170+
AsyncHTTPTransport(config, client=self._session_provider()),
172171
)
173172
return AsyncPollingUpdateProcessor(config, feature_requester, store, ready)

ldclient/testing/impl/test_async_big_segments.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -64,8 +64,10 @@ def membership_queries(self):
6464

6565
async def make_started_manager(store, **kwargs):
6666
config = AsyncBigSegmentsConfig(store=store, **kwargs)
67-
# The constructor starts the polling task (it requires a running event loop).
68-
return AsyncBigSegmentStoreManager(config)
67+
manager = AsyncBigSegmentStoreManager(config)
68+
# start() begins the polling task (it requires a running event loop).
69+
manager.start()
70+
return manager
6971

7072

7173
@pytest.mark.asyncio

ldclient/testing/test_async_client.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -223,16 +223,17 @@ def after_evaluation(self, series_context, data, detail):
223223

224224
@pytest.mark.asyncio
225225
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()."""
226+
"""flag_tracker is available before start(); the data system it reads from is
227+
built in __init__ and not rebuilt by start(), so early listeners stay wired."""
228228
client = AsyncLDClient(_offline_config())
229229

230230
tracker = client.flag_tracker
231231
assert tracker is not None
232232
tracker.add_listener(lambda change: None)
233233

234+
data_system_before = client._data_system
234235
await client.start()
235-
assert client._data_system.flag_change_listeners is client._flag_change_listeners
236+
assert client._data_system is data_system_before
236237
await client.close()
237238

238239

0 commit comments

Comments
 (0)