Skip to content

Commit ed35d4a

Browse files
committed
chore: Support persistent data store verification in contract tests
BEGIN_COMMIT_OVERRIDE chore: Support persistent data store verification in contract tests fix: Update Redis to write missing `$inited` key fix: Redis store is considered initialized when `$inited` key is written END_COMMIT_OVERRIDE
1 parent cbfc3dd commit ed35d4a

File tree

5 files changed

+106
-5
lines changed

5 files changed

+106
-5
lines changed

.github/workflows/ci.yml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,13 +62,15 @@ jobs:
6262
test_service_port: 9000
6363
token: ${{ secrets.GITHUB_TOKEN }}
6464
stop_service: 'false'
65+
enable_persistence_tests: 'true'
6566

6667
- name: Run contract tests v3
6768
uses: launchdarkly/gh-actions/actions/contract-tests@contract-tests-v1
6869
with:
6970
test_service_port: 9000
7071
token: ${{ secrets.GITHUB_TOKEN }}
71-
version: v3.0.0-alpha.1
72+
version: v3.0.0-alpha.2
73+
enable_persistence_tests: 'true'
7274

7375
windows:
7476
runs-on: windows-latest

contract-tests/client_entity.py

Lines changed: 93 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
1+
import sys
12
import json
23
import logging
4+
from urllib.parse import urlparse
35

46
import requests
57
from big_segment_store_fixture import BigSegmentStoreFixture
@@ -21,7 +23,10 @@
2123
polling_ds_builder,
2224
streaming_ds_builder
2325
)
26+
from ldclient.feature_store import CacheConfig
2427
from ldclient.impl.datasourcev2.polling import PollingDataSourceBuilder
28+
from ldclient.integrations import Consul, DynamoDB, Redis
29+
from ldclient.interfaces import DataStoreMode
2530

2631

2732
class ClientEntity:
@@ -102,6 +107,19 @@ def __init__(self, tag, config):
102107
if datasystem_config.get("payloadFilter") is not None:
103108
opts["payload_filter_key"] = datasystem_config["payloadFilter"]
104109

110+
# Handle persistent data store configuration for dataSystem
111+
store_config = datasystem_config.get("store")
112+
if store_config is not None:
113+
persistent_store_config = store_config.get("persistentDataStore")
114+
if persistent_store_config is not None:
115+
store = _create_persistent_store(persistent_store_config)
116+
117+
# Parse store mode (0 = READ_ONLY, 1 = READ_WRITE)
118+
store_mode_value = datasystem_config.get("storeMode", 0)
119+
store_mode = DataStoreMode.READ_WRITE if store_mode_value == 1 else DataStoreMode.READ_ONLY
120+
121+
datasystem.data_store(store, store_mode)
122+
105123
opts["datasystem_config"] = datasystem.build()
106124

107125
elif config.get("streaming") is not None:
@@ -111,14 +129,16 @@ def __init__(self, tag, config):
111129
if streaming.get("filter") is not None:
112130
opts["payload_filter_key"] = streaming["filter"]
113131
_set_optional_time_prop(streaming, "initialRetryDelayMs", opts, "initial_reconnect_delay")
114-
else:
132+
elif config.get("polling") is not None:
115133
opts['stream'] = False
116134
polling = config["polling"]
117135
if polling.get("baseUri") is not None:
118136
opts["base_uri"] = polling["baseUri"]
119137
if polling.get("filter") is not None:
120138
opts["payload_filter_key"] = polling["filter"]
121139
_set_optional_time_prop(polling, "pollIntervalMs", opts, "poll_interval")
140+
else:
141+
opts['use_ldd'] = True
122142

123143
if config.get("events") is not None:
124144
events = config["events"]
@@ -148,6 +168,9 @@ def __init__(self, tag, config):
148168
_set_optional_time_prop(big_params, "staleAfterMs", big_config, "stale_after")
149169
opts["big_segments"] = BigSegmentsConfig(**big_config)
150170

171+
if config.get("persistentDataStore") is not None:
172+
opts["feature_store"] = _create_persistent_store(config["persistentDataStore"])
173+
151174
start_wait = config.get("startWaitTimeMs") or 5000
152175
config = Config(**opts)
153176

@@ -285,3 +308,72 @@ def _set_optional_time_prop(params_in: dict, name_in: str, params_out: dict, nam
285308
if params_in.get(name_in) is not None:
286309
params_out[name_out] = params_in[name_in] / 1000.0
287310
return None
311+
312+
313+
def _create_persistent_store(persistent_store_config: dict):
314+
"""
315+
Creates a persistent store instance based on the configuration.
316+
Used for both v2 and v3 (dataSystem) configurations.
317+
"""
318+
store_params = persistent_store_config["store"]
319+
store_type = store_params["type"]
320+
dsn = store_params["dsn"]
321+
prefix = store_params.get("prefix")
322+
323+
# Parse cache configuration
324+
cache_config = persistent_store_config.get("cache", {})
325+
cache_mode = cache_config.get("mode", "ttl")
326+
327+
if cache_mode == "off":
328+
caching = CacheConfig.disabled()
329+
elif cache_mode == "infinite":
330+
caching = CacheConfig(expiration=sys.maxsize)
331+
elif cache_mode == "ttl":
332+
ttl_seconds = cache_config.get("ttl", 15)
333+
caching = CacheConfig(expiration=ttl_seconds)
334+
else:
335+
caching = CacheConfig.default()
336+
337+
# Create the appropriate store based on type
338+
if store_type == "redis":
339+
return Redis.new_feature_store(
340+
url=dsn,
341+
prefix=prefix or Redis.DEFAULT_PREFIX,
342+
caching=caching
343+
)
344+
elif store_type == "dynamodb":
345+
# Parse endpoint from DSN
346+
parsed = urlparse(dsn)
347+
endpoint_url = f"{parsed.scheme}://{parsed.netloc}"
348+
349+
# Import boto3 for DynamoDB configuration
350+
import boto3
351+
352+
# Create DynamoDB client with test credentials
353+
dynamodb_opts = {
354+
'endpoint_url': endpoint_url,
355+
'region_name': 'us-east-1',
356+
'aws_access_key_id': 'dummy',
357+
'aws_secret_access_key': 'dummy'
358+
}
359+
360+
return DynamoDB.new_feature_store(
361+
table_name="sdk-contract-tests",
362+
prefix=prefix,
363+
dynamodb_opts=dynamodb_opts,
364+
caching=caching
365+
)
366+
elif store_type == "consul":
367+
# Parse host and port from DSN
368+
parsed = urlparse(dsn) if '://' in dsn else urlparse(f'http://{dsn}')
369+
host = parsed.hostname or 'localhost'
370+
port = parsed.port or 8500
371+
372+
return Consul.new_feature_store(
373+
host=host,
374+
port=port,
375+
prefix=prefix,
376+
caching=caching
377+
)
378+
else:
379+
raise ValueError(f"Unsupported data store type: {store_type}")

contract-tests/service.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,9 @@ def status():
7979
'evaluation-hooks',
8080
'omit-anonymous-contexts',
8181
'client-prereq-events',
82+
'persistent-data-store-redis',
83+
'persistent-data-store-dynamodb',
84+
'persistent-data-store-consul',
8285
]
8386
}
8487
return json.dumps(body), 200, {'Content-type': 'application/json'}

ldclient/client.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -561,8 +561,8 @@ def _evaluate_internal(self, key: str, context: Context, default: Any, event_fac
561561
if self._config.offline:
562562
return EvaluationDetail(default, None, error_reason('CLIENT_NOT_READY')), None
563563

564-
if not self.is_initialized():
565-
if self._data_system.store.initialized:
564+
if self._data_system.data_availability != DataAvailability.REFRESHED:
565+
if self._data_system.data_availability == DataAvailability.CACHED:
566566
log.warning("Feature Flag evaluation attempted before client has initialized - using last known values from feature store for feature key: " + key)
567567
else:
568568
log.warning("Feature Flag evaluation attempted before client has initialized! Feature store unavailable - returning default: " + str(default) + " for feature key: " + key)

ldclient/impl/integrations/redis/redis_feature_store.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
from typing import Any, Dict
33

44
from ldclient import log
5+
from ldclient.feature_store_helpers import CachingStoreWrapper
56
from ldclient.impl.util import redact_password
67
from ldclient.interfaces import DiagnosticDescription, FeatureStoreCore
78
from ldclient.versioned_data_kind import FEATURES
@@ -20,6 +21,7 @@ def __init__(self, url, prefix, redis_opts: Dict[str, Any]):
2021
if not have_redis:
2122
raise NotImplementedError("Cannot use Redis feature store because redis package is not installed")
2223
self._prefix = prefix or 'launchdarkly'
24+
self._init_key = "{0}:{1}".format(self._prefix, CachingStoreWrapper.__INITED_CACHE_KEY__)
2325
self._pool = redis.ConnectionPool.from_url(url=url, **redis_opts)
2426
self.test_update_hook = None # exposed for testing
2527
log.info("Started RedisFeatureStore connected to URL: " + redact_password(url) + " using prefix: " + self._prefix)
@@ -46,6 +48,8 @@ def init_internal(self, all_data):
4648
item_json = json.dumps(item)
4749
pipe.hset(base_key, key, item_json)
4850
all_count = all_count + len(items)
51+
52+
pipe.set(self._init_key, self._init_key)
4953
pipe.execute()
5054
log.info("Initialized RedisFeatureStore with %d items", all_count)
5155

@@ -109,7 +113,7 @@ def upsert_internal(self, kind, item):
109113

110114
def initialized_internal(self):
111115
r = redis.Redis(connection_pool=self._pool)
112-
return r.exists(self._items_key(FEATURES))
116+
return r.exists(self._init_key)
113117

114118
def describe_configuration(self, config):
115119
return 'Redis'

0 commit comments

Comments
 (0)