Skip to content

Commit bd2ffdf

Browse files
committed
chore: Run the async contract tests in CI and wire the service for FDv2
The async contract-test service has never run in CI, so the async data systems had no contract coverage there — this is how the async FDv1 warm-start gap stayed invisible. This adds an async contract-test run (v2 and v3, persistence enabled) alongside the existing sync run, and wires the async service to build an FDv2 data system from a dataSystem config so the v3 suite exercises AsyncFDv2. Stacked on #486 (the async FDv2 data system), whose client wiring the FDv2 contract path depends on; retarget to main after #486 merges.
1 parent 6a70132 commit bd2ffdf

4 files changed

Lines changed: 171 additions & 3 deletions

File tree

.github/workflows/ci.yml

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,29 @@ jobs:
7070
version: v3
7171
enable_persistence_tests: "true"
7272

73+
#
74+
# Async SDK contract tests
75+
#
76+
77+
- name: start async contract test service
78+
run: make start-async-contract-test-service-bg
79+
80+
- name: Run async contract tests v2
81+
uses: launchdarkly/gh-actions/actions/contract-tests@contract-tests-v1
82+
with:
83+
test_service_port: 9001
84+
token: ${{ secrets.GITHUB_TOKEN }}
85+
stop_service: "false"
86+
enable_persistence_tests: "true"
87+
88+
- name: Run async contract tests v3
89+
uses: launchdarkly/gh-actions/actions/contract-tests@contract-tests-v1
90+
with:
91+
test_service_port: 9001
92+
token: ${{ secrets.GITHUB_TOKEN }}
93+
version: v3
94+
enable_persistence_tests: "true"
95+
7396
windows:
7497
runs-on: windows-latest
7598

Makefile

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,15 @@ start-contract-test-service-bg:
7070
@echo "Test service output will be captured in $(TEMP_TEST_OUTPUT)"
7171
@make start-contract-test-service >$(TEMP_TEST_OUTPUT) 2>&1 &
7272

73+
.PHONY: start-async-contract-test-service
74+
start-async-contract-test-service: install-contract-tests-deps
75+
@cd contract-tests && uv run python async_service.py 9001
76+
77+
.PHONY: start-async-contract-test-service-bg
78+
start-async-contract-test-service-bg:
79+
@echo "Async test service output will be captured in /tmp/async-contract-test-service.log"
80+
@make start-async-contract-test-service >/tmp/async-contract-test-service.log 2>&1 &
81+
7382
.PHONY: run-contract-tests
7483
run-contract-tests:
7584
@curl -s https://raw.githubusercontent.com/launchdarkly/sdk-test-harness/v2/downloader/run.sh \

contract-tests/async_client_entity.py

Lines changed: 137 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import asyncio
22
import json
33
import logging
4-
from typing import Optional
4+
import sys
5+
from typing import Any, Callable, Optional
56

67
import requests
78
from async_big_segment_store_fixture import AsyncBigSegmentStoreFixture
@@ -10,8 +11,22 @@
1011

1112
from ldclient import Context
1213
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+
)
1427
from ldclient.impl.util import Result
28+
from ldclient.integrations import Redis
29+
from ldclient.interfaces import DataStoreMode
1530
from ldclient.migrations import (
1631
AsyncMigratorBuilder,
1732
ExecutionOrder,
@@ -42,7 +57,7 @@ async def start(self):
4257

4358
datasystem_config = config_params.get('dataSystem')
4459
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)
4661
elif config_params.get("streaming") is not None:
4762
streaming = config_params["streaming"]
4863
if streaming.get("baseUri") is not None:
@@ -93,6 +108,9 @@ async def start(self):
93108
_set_optional_time_prop(big_params, "staleAfterMs", big_config, "stale_after")
94109
opts["big_segments"] = AsyncBigSegmentsConfig(**big_config)
95110

111+
if config_params.get("persistentDataStore") is not None:
112+
opts["feature_store"] = _create_async_persistent_store(config_params["persistentDataStore"])
113+
96114
start_wait = config_params.get("startWaitTimeMs") or 5000
97115
sdk_config = AsyncConfig(**opts)
98116

@@ -267,3 +285,119 @@ async def close(self):
267285
def _set_optional_time_prop(params_in: dict, name_in: str, params_out: dict, name_out: str):
268286
if params_in.get(name_in) is not None:
269287
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}")

contract-tests/async_service.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,8 @@ async def handle_status(request: aiohttp.web.Request) -> aiohttp.web.Response:
6767
'flag-change-listeners',
6868
'flag-value-change-listeners',
6969
'migrations',
70+
'persistent-data-store-redis',
71+
'fdv1-fallback',
7072
]
7173
}
7274
return aiohttp.web.Response(

0 commit comments

Comments
 (0)