Skip to content

Commit 0db5afa

Browse files
committed
feat: Add the async FDv2 data system
1 parent 5f44e61 commit 0db5afa

11 files changed

Lines changed: 2319 additions & 153 deletions

ldclient/async_client.py

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,12 @@ async def __start_up(self, start_wait: float):
197197
# Start the big-segment status poll now that a loop is running.
198198
self.__big_segment_store_manager.start()
199199

200+
# FDv2 builds its data sources from builders; wire the shared session into
201+
# them before starting (FDv1 pulls the session itself via its provider).
202+
datasystem_config = self._config.datasystem_config
203+
if datasystem_config is not None and not self._config.offline:
204+
self._wire_data_source_sessions(datasystem_config)
205+
200206
if self._config.offline:
201207
log.info("Started LaunchDarkly Client in offline mode")
202208

@@ -221,6 +227,10 @@ async def __start_up(self, start_wait: float):
221227
log.info("Waiting up to " + str(start_wait) + " seconds for LaunchDarkly client to initialize...")
222228
await update_processor_ready.wait(start_wait)
223229

230+
# Warm the persistent store's initialized state so a store populated by
231+
# another process is recognized before the readiness check.
232+
await self._data_system.refresh_availability()
233+
224234
if self.is_initialized() is True:
225235
log.info("Started LaunchDarkly Client: OK")
226236
else:
@@ -243,7 +253,9 @@ def _make_data_system(self) -> AsyncDataSystem:
243253

244254
return AsyncFDv1(self._config, self._select_feature_store(), self._get_session)
245255

246-
raise NotImplementedError("FDv2 is not yet supported in the async client")
256+
from ldclient.impl.datasystem.async_fdv2 import AsyncFDv2
257+
258+
return AsyncFDv2(self._config, datasystem_config)
247259

248260
def _select_feature_store(self) -> AsyncFeatureStore:
249261
"""Choose the async feature store for the v1 data system based on the
@@ -253,6 +265,34 @@ def _select_feature_store(self) -> AsyncFeatureStore:
253265
return AsyncInMemoryFeatureStore()
254266
return feature_store
255267

268+
def _wire_data_source_sessions(self, data_system_config) -> None:
269+
"""Provide the client's aiohttp session to any async data source
270+
builders so the sources they build share the client's connection pool."""
271+
from ldclient.impl.datasourcev2.async_polling import (
272+
AsyncFallbackToFDv1PollingDataSourceBuilder,
273+
AsyncPollingDataSourceBuilder
274+
)
275+
from ldclient.impl.datasourcev2.async_streaming import (
276+
AsyncStreamingDataSourceBuilder
277+
)
278+
279+
builders = list(data_system_config.initializers or []) + list(
280+
data_system_config.synchronizers or []
281+
)
282+
if data_system_config.fdv1_fallback_synchronizer is not None:
283+
builders.append(data_system_config.fdv1_fallback_synchronizer)
284+
285+
for builder in builders:
286+
if isinstance(
287+
builder,
288+
(
289+
AsyncFallbackToFDv1PollingDataSourceBuilder,
290+
AsyncPollingDataSourceBuilder,
291+
AsyncStreamingDataSourceBuilder,
292+
),
293+
):
294+
builder.session(self._get_session())
295+
256296
async def __register_plugins(self, environment_metadata: EnvironmentMetadata):
257297
for plugin in self._config.plugins:
258298
try:
@@ -455,6 +495,11 @@ async def _evaluate_internal(self, key: str, context: Context, default: Any, eve
455495
if self._config.offline:
456496
return EvaluationDetail(default, None, error_reason('CLIENT_NOT_READY')), None
457497

498+
# Refresh the store's initialized state only while still uninitialized;
499+
# once initialized or a basis arrives, the gate reads a cached value.
500+
if self._data_system.data_availability == DataAvailability.DEFAULTS:
501+
await self._data_system.refresh_availability()
502+
458503
if self._data_system.data_availability != DataAvailability.REFRESHED:
459504
if self._data_system.data_availability == DataAvailability.CACHED:
460505
log.warning("Feature Flag evaluation attempted before client has initialized - using last known values from feature store for feature key: " + key)
@@ -526,6 +571,11 @@ async def all_flags_state(self, context: Context, **kwargs) -> FeatureFlagsState
526571
log.warning("all_flags_state() called, but client is in offline mode. Returning empty state")
527572
return FeatureFlagsState(False)
528573

574+
# Refresh the store's initialized state only while still uninitialized;
575+
# once initialized or a basis arrives, the gate reads a cached value.
576+
if self._data_system.data_availability == DataAvailability.DEFAULTS:
577+
await self._data_system.refresh_availability()
578+
529579
if self._data_system.data_availability != DataAvailability.REFRESHED:
530580
if self._data_system.data_availability == DataAvailability.CACHED:
531581
log.warning("all_flags_state() called before client has finished initializing! Using last known values from feature store")

ldclient/async_feature_store_helpers.py

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@ class AsyncCachingStoreWrapper(_CachingStoreWrapperBase, DiagnosticDescription,
3737
event loop because its reads and writes never suspend between one another.
3838
"""
3939

40+
__INITED_CACHE_KEY__ = "$inited"
41+
4042
_core: AsyncFeatureStoreCore
4143

4244
def __init__(self, core: AsyncFeatureStoreCore, cache_config: CacheConfig):
@@ -95,12 +97,25 @@ async def upsert(self, kind: VersionedDataKind, item: dict) -> bool:
9597

9698
@property
9799
def initialized(self) -> bool:
98-
"""Returns whether ``init`` has completed in this process.
100+
"""Returns the store's last observed initialized state."""
101+
return self._inited
99102

100-
This property does not query the store: it is synchronous, but a persistent-store query is
101-
a coroutine, so it reflects only whether this process has initialized the store.
103+
async def refresh_initialized(self) -> None:
104+
"""Refreshes :attr:`initialized` from the store's own initialized state.
105+
106+
Honors the cache: with caching off the store is queried on every call;
107+
with a TTL it is queried once per interval; with an infinite TTL it is
108+
queried once. Once the store reports initialized the state latches and
109+
later calls return without I/O.
102110
"""
103-
return self._inited
111+
if self._inited:
112+
return
113+
result = self._cache.get(AsyncCachingStoreWrapper.__INITED_CACHE_KEY__)
114+
if result is None:
115+
result = bool(await self._core.initialized_internal())
116+
self._cache[AsyncCachingStoreWrapper.__INITED_CACHE_KEY__] = result
117+
if result:
118+
self._inited = True
104119

105120
async def close(self) -> None:
106121
"""Releases the cache and closes the underlying core if it supports it."""

ldclient/impl/datasystem/__init__.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -232,6 +232,18 @@ def store(self) -> AsyncReadOnlyStore:
232232
"""
233233
raise NotImplementedError
234234

235+
@abstractmethod
236+
async def refresh_availability(self) -> None:
237+
"""
238+
Refreshes any awaited state that :attr:`data_availability` depends on.
239+
240+
The client awaits this before reading :attr:`data_availability` so that a
241+
persistent store initialized by another process is reflected while a data
242+
source is configured but has not yet supplied a basis. Data systems with
243+
no such state treat this as a no-op.
244+
"""
245+
raise NotImplementedError
246+
235247

236248
class DiagnosticAccumulator(Protocol):
237249
def record_stream_init(self, timestamp, duration, failed):

ldclient/impl/datasystem/async_fdv1.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,10 @@ async def stop(self):
124124
def store(self) -> AsyncReadOnlyStore:
125125
return self._store_view
126126

127+
async def refresh_availability(self) -> None:
128+
"""No-op; ``data_availability`` reads the store's initialized state directly."""
129+
return None
130+
127131
def set_diagnostic_accumulator(self, diagnostic_accumulator: DiagnosticAccumulator):
128132
"""
129133
Sets the diagnostic accumulator for streaming initialization metrics.

0 commit comments

Comments
 (0)