Skip to content

Commit 6a70132

Browse files
authored
feat: Add async FDv2 data system (#486)
## Overview Part of the async Python SDK work (epic SDK-60). Adds the async FDv2 **data system** (coordinator) and wires it into the async client. Targets `main` (its predecessor, #485 async FDv2 data sources, has merged). This is experimental and should not be considered production-ready. ## What this PR adds - `impl/datasystem/async_fdv2.py` — `AsyncFDv2`, the async data system that coordinates the async initializers and synchronizers, mirrors the sync `FDv2` fallback/recovery behavior, and exposes the async data source status and flag tracking. Includes `AsyncFeatureStoreClientWrapper` for persistent-store availability polling. - `async_client.py` wiring: `_make_data_system` builds `AsyncFDv2`; `_wire_data_source_sessions` shares the client's aiohttp session into the async data source builders so they reuse the connection pool. ## Async readiness gate (awaitable end-to-end) Readiness and availability are now awaitable so a persistent store populated by another process (warm start / daemon) is recognized: - `AsyncDataSystem.data_availability` and `AsyncLDClient.is_initialized()` are coroutines; the eval path awaits `data_availability()` directly. - `AsyncFeatureStore` gains an abstract async `is_initialized()` (with `$inited` caching + monotonic latch in `AsyncCachingStoreWrapper`, surfaced via `AsyncStore.is_ready()`). The `getattr` duck-type is dropped, so a custom store that omits it fails at construction rather than silently serving defaults. - The gate catches a persistent-store error and degrades to `DEFAULTS` rather than raising, so `variation()` never throws on a store outage. (Sync counterpart: #506.) ## Wrapper hardening - `is_monitoring_enabled()` delegates to the store's own opt-in (matching sync), so a store that cannot report availability is not polled and left stuck unavailable. - `close()` is idempotent, bounds the availability-poller stop with a timeout, and logs errors from the inner store close. - `AsyncStore.close()` logs and swallows close errors instead of returning them. ## Shared refactor - `impl/datasystem/fdv2_common.py` gains module-level `fallback_condition` / `recovery_condition`, shared by the sync and async data systems. ## Testing - `LD_SKIP_DATABASE_TESTS=1 uv run pytest ldclient/testing/impl/datasystem/` and `ldclient/testing/test_async_client.py` — green. - `make lint` (mypy, isort, pycodestyle) — clean. Tracked internally: SDK-2870 <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Overview** > Adds **async Flag Delivery v2** support by introducing `AsyncFDv2`, wiring it into `AsyncLDClient` when `datasystem_config` is set (replacing the previous `NotImplementedError`), and sharing the client's aiohttp session with async polling/streaming data source builders via `_wire_data_source_sessions`. > > **Readiness and availability are now async** so persistent stores populated by another process can be detected: `AsyncLDClient.is_initialized()`, `AsyncDataSystem.data_availability()`, and a new `AsyncFeatureStore.is_initialized()` (with caching/latching in `AsyncCachingStoreWrapper` and `AsyncStore.is_ready()`). Contract-test harnesses await these checks accordingly. > > Sync **FDv2** is refactored to share wiring through `_FDv2Base` and module-level `fallback_condition` / `recovery_condition` in `fdv2_common.py`. `AsyncFDv2` mirrors initializer/synchronizer coordination, FDv1 fallback, persistent-store outage recovery, and adds `AsyncFeatureStoreClientWrapper` for availability polling. > > `AsyncStore.close()` logs and swallows close errors instead of returning them. Large new test suites cover `AsyncFDv2`, async persistence, and readiness gating. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 111bb37. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent 5f44e61 commit 6a70132

18 files changed

Lines changed: 2439 additions & 186 deletions

contract-tests/async_client_entity.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -104,8 +104,8 @@ async def start(self):
104104
await self._client.start(start_wait / 1000.0)
105105
self._listeners = AsyncListenerRegistry(self._client.flag_tracker)
106106

107-
def is_initializing(self) -> bool:
108-
return self._client.is_initialized() if self._client else False
107+
async def is_initializing(self) -> bool:
108+
return await self._client.is_initialized() if self._client else False
109109

110110
async def evaluate(self, params: dict) -> dict:
111111
response = {}

contract-tests/async_service.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,7 @@ async def handle_create_client(request: aiohttp.web.Request) -> aiohttp.web.Resp
102102
await client.close()
103103
return aiohttp.web.Response(text=str(e), status=500)
104104

105-
if not client.is_initializing() and not options['configuration'].get('initCanFail', False):
105+
if not await client.is_initializing() and not options['configuration'].get('initCanFail', False):
106106
await client.close()
107107
return aiohttp.web.Response(text='Failed to initialize', status=500)
108108

ldclient/async_client.py

Lines changed: 48 additions & 8 deletions
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,7 +227,7 @@ 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

224-
if self.is_initialized() is True:
230+
if await self.is_initialized() is True:
225231
log.info("Started LaunchDarkly Client: OK")
226232
else:
227233
log.warning("Initialization timeout exceeded for LaunchDarkly Client or an error occurred. " "Feature Flags may not yet be available.")
@@ -243,7 +249,9 @@ def _make_data_system(self) -> AsyncDataSystem:
243249

244250
return AsyncFDv1(self._config, self._select_feature_store(), self._get_session)
245251

246-
raise NotImplementedError("FDv2 is not yet supported in the async client")
252+
from ldclient.impl.datasystem.async_fdv2 import AsyncFDv2
253+
254+
return AsyncFDv2(self._config, datasystem_config)
247255

248256
def _select_feature_store(self) -> AsyncFeatureStore:
249257
"""Choose the async feature store for the v1 data system based on the
@@ -253,6 +261,34 @@ def _select_feature_store(self) -> AsyncFeatureStore:
253261
return AsyncInMemoryFeatureStore()
254262
return feature_store
255263

264+
def _wire_data_source_sessions(self, data_system_config) -> None:
265+
"""Provide the client's aiohttp session to any async data source
266+
builders so the sources they build share the client's connection pool."""
267+
from ldclient.impl.datasourcev2.async_polling import (
268+
AsyncFallbackToFDv1PollingDataSourceBuilder,
269+
AsyncPollingDataSourceBuilder
270+
)
271+
from ldclient.impl.datasourcev2.async_streaming import (
272+
AsyncStreamingDataSourceBuilder
273+
)
274+
275+
builders = list(data_system_config.initializers or []) + list(
276+
data_system_config.synchronizers or []
277+
)
278+
if data_system_config.fdv1_fallback_synchronizer is not None:
279+
builders.append(data_system_config.fdv1_fallback_synchronizer)
280+
281+
for builder in builders:
282+
if isinstance(
283+
builder,
284+
(
285+
AsyncFallbackToFDv1PollingDataSourceBuilder,
286+
AsyncPollingDataSourceBuilder,
287+
AsyncStreamingDataSourceBuilder,
288+
),
289+
):
290+
builder.session(self._get_session())
291+
256292
async def __register_plugins(self, environment_metadata: EnvironmentMetadata):
257293
for plugin in self._config.plugins:
258294
try:
@@ -343,18 +379,20 @@ def is_offline(self) -> bool:
343379
"""Returns true if the client is in offline mode."""
344380
return self._config.offline
345381

346-
def is_initialized(self) -> bool:
382+
async def is_initialized(self) -> bool:
347383
"""Returns true if the client has successfully connected to LaunchDarkly.
348384
349385
If this returns false, it means that the client has not yet successfully connected to LaunchDarkly.
350386
It might still be in the process of starting up, or it might be attempting to reconnect after an
351387
unsuccessful attempt, or it might have received an unrecoverable error (such as an invalid SDK key)
352388
and given up.
389+
390+
This is a coroutine because determining readiness may query a persistent store.
353391
"""
354392
if self.is_offline() or self._config.use_ldd:
355393
return True
356394

357-
return self._data_system.data_availability.at_least(DataAvailability.CACHED)
395+
return (await self._data_system.data_availability()).at_least(DataAvailability.CACHED)
358396

359397
async def flush(self):
360398
"""Flushes all pending analytics events.
@@ -455,8 +493,9 @@ async def _evaluate_internal(self, key: str, context: Context, default: Any, eve
455493
if self._config.offline:
456494
return EvaluationDetail(default, None, error_reason('CLIENT_NOT_READY')), None
457495

458-
if self._data_system.data_availability != DataAvailability.REFRESHED:
459-
if self._data_system.data_availability == DataAvailability.CACHED:
496+
availability = await self._data_system.data_availability()
497+
if availability != DataAvailability.REFRESHED:
498+
if availability == DataAvailability.CACHED:
460499
log.warning("Feature Flag evaluation attempted before client has initialized - using last known values from feature store for feature key: " + key)
461500
else:
462501
log.warning("Feature Flag evaluation attempted before client has initialized! Feature store unavailable - returning default: " + str(default) + " for feature key: " + key)
@@ -526,8 +565,9 @@ async def all_flags_state(self, context: Context, **kwargs) -> FeatureFlagsState
526565
log.warning("all_flags_state() called, but client is in offline mode. Returning empty state")
527566
return FeatureFlagsState(False)
528567

529-
if self._data_system.data_availability != DataAvailability.REFRESHED:
530-
if self._data_system.data_availability == DataAvailability.CACHED:
568+
availability = await self._data_system.data_availability()
569+
if availability != DataAvailability.REFRESHED:
570+
if availability == DataAvailability.CACHED:
531571
log.warning("all_flags_state() called before client has finished initializing! Using last known values from feature store")
532572
else:
533573
log.warning("all_flags_state() called before client has finished initializing! Feature store unavailable - returning empty state")

ldclient/async_feature_store.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,10 @@ def initialized(self) -> bool:
8888
""" """
8989
return self._initialized
9090

91+
async def is_initialized(self) -> bool:
92+
""" """
93+
return self._initialized
94+
9195
async def close(self) -> None:
9296
""" """
9397
pass

ldclient/async_feature_store_helpers.py

Lines changed: 20 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,26 @@ 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 is_initialized(self) -> bool:
104+
"""Queries the store's initialized state, updating :attr:`initialized`.
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 True
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
119+
return result
104120

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

ldclient/impl/datasystem/__init__.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -208,11 +208,11 @@ def flag_change_listeners(self) -> Listeners:
208208
"""
209209
raise NotImplementedError
210210

211-
@property
212211
@abstractmethod
213-
def data_availability(self) -> DataAvailability:
212+
async def data_availability(self) -> DataAvailability:
214213
"""
215-
Indicates what form of data is currently available.
214+
Indicates what form of data is currently available, awaiting the store's
215+
readiness so a persistent store populated by another process is recognized.
216216
"""
217217
raise NotImplementedError
218218

ldclient/impl/datasystem/async_fdv1.py

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -143,18 +143,22 @@ def data_store_status_provider(self) -> DataStoreStatusProvider:
143143
def flag_change_listeners(self) -> Listeners:
144144
return self._flag_change_listeners
145145

146-
@property
147-
def data_availability(self) -> DataAvailability:
146+
async def data_availability(self) -> DataAvailability:
148147
if self._config.offline:
149148
return DataAvailability.DEFAULTS
150149

151150
if self._update_processor is not None and self._update_processor.initialized():
152151
return DataAvailability.REFRESHED
153152

154-
if self._store.initialized:
155-
return DataAvailability.CACHED
153+
# Awaits the store so a persistent store populated by another process is
154+
# recognized. A persistent-store error is logged and reported as no data.
155+
try:
156+
ready = await self._store.is_initialized()
157+
except Exception as e:
158+
log.warning("Error checking persistent store readiness: %s", e)
159+
return DataAvailability.DEFAULTS
156160

157-
return DataAvailability.DEFAULTS
161+
return DataAvailability.CACHED if ready else DataAvailability.DEFAULTS
158162

159163
@property
160164
def target_availability(self) -> DataAvailability:

0 commit comments

Comments
 (0)