|
1 | 1 | import asyncio |
2 | 2 | import json |
3 | 3 | import logging |
4 | | -from typing import Optional |
| 4 | +import sys |
| 5 | +from typing import Any, Callable, Optional |
5 | 6 |
|
6 | 7 | import requests |
7 | 8 | from async_big_segment_store_fixture import AsyncBigSegmentStoreFixture |
|
10 | 11 |
|
11 | 12 | from ldclient import Context |
12 | 13 | from ldclient.async_client import AsyncLDClient |
13 | | -from ldclient.async_config import AsyncBigSegmentsConfig, AsyncConfig |
| 14 | +from ldclient.async_config import ( |
| 15 | + AsyncBigSegmentsConfig, |
| 16 | + AsyncConfig, |
| 17 | + AsyncDataSystemConfig |
| 18 | +) |
| 19 | +from ldclient.feature_store import CacheConfig |
| 20 | +from ldclient.impl.datasourcev2.async_polling import ( |
| 21 | + AsyncFallbackToFDv1PollingDataSourceBuilder, |
| 22 | + AsyncPollingDataSourceBuilder |
| 23 | +) |
| 24 | +from ldclient.impl.datasourcev2.async_streaming import ( |
| 25 | + AsyncStreamingDataSourceBuilder |
| 26 | +) |
14 | 27 | from ldclient.impl.util import Result |
| 28 | +from ldclient.integrations import Redis |
| 29 | +from ldclient.interfaces import DataStoreMode |
15 | 30 | from ldclient.migrations import ( |
16 | 31 | AsyncMigratorBuilder, |
17 | 32 | ExecutionOrder, |
@@ -42,7 +57,7 @@ async def start(self): |
42 | 57 |
|
43 | 58 | datasystem_config = config_params.get('dataSystem') |
44 | 59 | if datasystem_config is not None: |
45 | | - raise NotImplementedError("FDv2 (dataSystem) is not yet supported in the async contract-test service") |
| 60 | + opts["datasystem_config"] = _build_async_data_system(datasystem_config, opts) |
46 | 61 | elif config_params.get("streaming") is not None: |
47 | 62 | streaming = config_params["streaming"] |
48 | 63 | if streaming.get("baseUri") is not None: |
@@ -93,6 +108,9 @@ async def start(self): |
93 | 108 | _set_optional_time_prop(big_params, "staleAfterMs", big_config, "stale_after") |
94 | 109 | opts["big_segments"] = AsyncBigSegmentsConfig(**big_config) |
95 | 110 |
|
| 111 | + if config_params.get("persistentDataStore") is not None: |
| 112 | + opts["feature_store"] = _create_async_persistent_store(config_params["persistentDataStore"]) |
| 113 | + |
96 | 114 | start_wait = config_params.get("startWaitTimeMs") or 5000 |
97 | 115 | sdk_config = AsyncConfig(**opts) |
98 | 116 |
|
@@ -267,3 +285,119 @@ async def close(self): |
267 | 285 | def _set_optional_time_prop(params_in: dict, name_in: str, params_out: dict, name_out: str): |
268 | 286 | if params_in.get(name_in) is not None: |
269 | 287 | params_out[name_out] = params_in[name_in] / 1000.0 |
| 288 | + |
| 289 | + |
| 290 | +def _set_optional_time(params_in: dict, name_in: str, func: Callable[[float], Any]): |
| 291 | + if params_in.get(name_in) is not None: |
| 292 | + func(params_in[name_in] / 1000.0) |
| 293 | + |
| 294 | + |
| 295 | +def _set_optional_value(params_in: dict, name_in: str, func: Callable[[Any], Any]): |
| 296 | + if params_in.get(name_in) is not None: |
| 297 | + func(params_in[name_in]) |
| 298 | + |
| 299 | + |
| 300 | +def _build_async_data_system(datasystem_config: dict, opts: dict) -> AsyncDataSystemConfig: |
| 301 | + """Build an AsyncDataSystemConfig from the harness's dataSystem config. |
| 302 | +
|
| 303 | + Wires the FDv2 initializers, the ordered synchronizer chain, the FDv1 |
| 304 | + fallback synchronizer, the payload filter, and an optional async |
| 305 | + persistent store. The async client injects its shared aiohttp session |
| 306 | + into these builders when it starts. |
| 307 | + """ |
| 308 | + initializers: Optional[list] = None |
| 309 | + init_configs = datasystem_config.get('initializers') |
| 310 | + if init_configs is not None: |
| 311 | + initializers = [] |
| 312 | + for init_config in init_configs: |
| 313 | + polling = init_config.get('polling') |
| 314 | + if polling is not None: |
| 315 | + polling_builder = AsyncPollingDataSourceBuilder() |
| 316 | + _set_optional_value(polling, "baseUri", polling_builder.base_uri) |
| 317 | + _set_optional_time(polling, "pollIntervalMs", polling_builder.poll_interval) |
| 318 | + initializers.append(polling_builder) |
| 319 | + |
| 320 | + synchronizers: Optional[list] = None |
| 321 | + sync_configs = datasystem_config.get('synchronizers') |
| 322 | + if sync_configs is not None: |
| 323 | + sync_builders: list = [] |
| 324 | + for sync_config in sync_configs: |
| 325 | + streaming = sync_config.get('streaming') |
| 326 | + if streaming is not None: |
| 327 | + builder: Any = AsyncStreamingDataSourceBuilder() |
| 328 | + _set_optional_value(streaming, "baseUri", builder.base_uri) |
| 329 | + _set_optional_time(streaming, "initialRetryDelayMs", builder.initial_reconnect_delay) |
| 330 | + sync_builders.append(builder) |
| 331 | + elif sync_config.get('polling') is not None: |
| 332 | + polling = sync_config.get('polling') |
| 333 | + builder = AsyncPollingDataSourceBuilder() |
| 334 | + _set_optional_value(polling, "baseUri", builder.base_uri) |
| 335 | + _set_optional_time(polling, "pollIntervalMs", builder.poll_interval) |
| 336 | + sync_builders.append(builder) |
| 337 | + if sync_builders: |
| 338 | + synchronizers = sync_builders |
| 339 | + |
| 340 | + # The FDv1 Fallback Synchronizer engages only when the server sends an FDv1 |
| 341 | + # Fallback Directive; it is configured apart from the FDv2 synchronizer chain. |
| 342 | + fdv1_fallback_synchronizer = None |
| 343 | + fdv1_fallback_config = datasystem_config.get('fdv1Fallback') |
| 344 | + if fdv1_fallback_config is not None: |
| 345 | + fallback_builder = AsyncFallbackToFDv1PollingDataSourceBuilder() |
| 346 | + _set_optional_value(fdv1_fallback_config, "baseUri", fallback_builder.base_uri) |
| 347 | + _set_optional_time(fdv1_fallback_config, "pollIntervalMs", fallback_builder.poll_interval) |
| 348 | + fdv1_fallback_synchronizer = fallback_builder |
| 349 | + |
| 350 | + if datasystem_config.get("payloadFilter") is not None: |
| 351 | + opts["payload_filter_key"] = datasystem_config["payloadFilter"] |
| 352 | + |
| 353 | + ds_kwargs: dict = { |
| 354 | + "initializers": initializers, |
| 355 | + "synchronizers": synchronizers, |
| 356 | + "fdv1_fallback_synchronizer": fdv1_fallback_synchronizer, |
| 357 | + } |
| 358 | + |
| 359 | + store_config = datasystem_config.get("store") |
| 360 | + if store_config is not None: |
| 361 | + persistent_store_config = store_config.get("persistentDataStore") |
| 362 | + if persistent_store_config is not None: |
| 363 | + ds_kwargs["data_store"] = _create_async_persistent_store(persistent_store_config) |
| 364 | + # storeMode: 0 = READ_ONLY, 1 = READ_WRITE. |
| 365 | + store_mode_value = datasystem_config.get("storeMode", 0) |
| 366 | + ds_kwargs["data_store_mode"] = ( |
| 367 | + DataStoreMode.READ_WRITE if store_mode_value == 1 else DataStoreMode.READ_ONLY |
| 368 | + ) |
| 369 | + |
| 370 | + return AsyncDataSystemConfig(**ds_kwargs) |
| 371 | + |
| 372 | + |
| 373 | +def _create_async_persistent_store(persistent_store_config: dict): |
| 374 | + """Create an async persistent feature store from the harness config. |
| 375 | +
|
| 376 | + Only Redis has an async feature store, so any other store type is rejected. |
| 377 | + """ |
| 378 | + store_params = persistent_store_config["store"] |
| 379 | + store_type = store_params["type"] |
| 380 | + dsn = store_params["dsn"] |
| 381 | + prefix = store_params.get("prefix") |
| 382 | + |
| 383 | + cache_config = persistent_store_config.get("cache", {}) |
| 384 | + cache_mode = cache_config.get("mode", "ttl") |
| 385 | + |
| 386 | + if cache_mode == "off": |
| 387 | + caching = CacheConfig.disabled() |
| 388 | + elif cache_mode == "infinite": |
| 389 | + caching = CacheConfig(expiration=sys.maxsize) |
| 390 | + elif cache_mode == "ttl": |
| 391 | + ttl_seconds = cache_config.get("ttl", 15) |
| 392 | + caching = CacheConfig(expiration=ttl_seconds) |
| 393 | + else: |
| 394 | + caching = CacheConfig.default() |
| 395 | + |
| 396 | + if store_type == "redis": |
| 397 | + return Redis.async_feature_store( |
| 398 | + url=dsn, |
| 399 | + prefix=prefix or Redis.DEFAULT_PREFIX, |
| 400 | + caching=caching |
| 401 | + ) |
| 402 | + |
| 403 | + raise ValueError(f"Unsupported async data store type: {store_type}") |
0 commit comments