Skip to content

Commit 569a687

Browse files
authored
Merge branch 'main' into jb/sdk-2922/float-v3-contract-tests
2 parents bd9f14c + 90059cb commit 569a687

22 files changed

Lines changed: 1300 additions & 294 deletions

ldclient/async_config.py

Lines changed: 41 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
compatibility guarantees.
99
"""
1010

11+
from dataclasses import dataclass
1112
from typing import Callable, List, Optional, Set
1213

1314
from ldclient.async_feature_store import AsyncInMemoryFeatureStore
@@ -17,8 +18,8 @@
1718
DEFAULT_STREAM_URI,
1819
GET_LATEST_FEATURES_PATH,
1920
STREAM_FLAGS_PATH,
21+
DataSourceBuilder,
2022
DataSourceBuilderConfig,
21-
DataSystemConfig,
2223
HTTPConfig,
2324
PrivateAttributesConfig
2425
)
@@ -34,7 +35,10 @@
3435
AsyncDataSourceUpdateSink,
3536
AsyncEventProcessor,
3637
AsyncFeatureStore,
37-
AsyncUpdateProcessor
38+
AsyncInitializer,
39+
AsyncSynchronizer,
40+
AsyncUpdateProcessor,
41+
DataStoreMode
3842
)
3943
from ldclient.plugin import AsyncPlugin
4044

@@ -91,6 +95,39 @@ def stale_after(self) -> float:
9195
return self.__stale_after
9296

9397

98+
@dataclass(frozen=True)
99+
class AsyncDataSystemConfig:
100+
"""Configuration for the async SDK's data acquisition strategy.
101+
102+
This mirrors :class:`ldclient.config.DataSystemConfig` for the async client.
103+
Its data sources are async builders and its data store is an async store.
104+
105+
.. caution::
106+
This feature is experimental and should NOT be considered ready for production
107+
use. It may change or be removed without notice and is not subject to backwards
108+
compatibility guarantees.
109+
"""
110+
111+
initializers: Optional[List[DataSourceBuilder[AsyncInitializer]]] = None
112+
"""The initializers for the data system."""
113+
114+
synchronizers: Optional[List[DataSourceBuilder[AsyncSynchronizer]]] = None
115+
"""
116+
The synchronizers for the data system, ordered by preference.
117+
The first synchronizer is the most preferred, with subsequent synchronizers
118+
serving as fallbacks in order of decreasing preference.
119+
"""
120+
121+
data_store_mode: DataStoreMode = DataStoreMode.READ_WRITE
122+
"""The data store mode specifies the mode in which the persistent store will operate, if present."""
123+
124+
data_store: Optional[AsyncFeatureStore] = None
125+
"""The (optional) async persistent data store instance."""
126+
127+
fdv1_fallback_synchronizer: Optional[DataSourceBuilder[AsyncSynchronizer]] = None
128+
"""An optional fallback synchronizer that will read from FDv1"""
129+
130+
94131
class AsyncConfig(DataSourceBuilderConfig, PrivateAttributesConfig):
95132
"""Advanced configuration options for the async SDK client.
96133
@@ -138,7 +175,7 @@ def __init__(
138175
enable_event_compression: bool = False,
139176
omit_anonymous_contexts: bool = False,
140177
payload_filter_key: Optional[str] = None,
141-
datasystem_config: Optional[DataSystemConfig] = None,
178+
datasystem_config: Optional[AsyncDataSystemConfig] = None,
142179
):
143180
"""
144181
:param sdk_key: The SDK key for your LaunchDarkly account. This is always required.
@@ -466,7 +503,7 @@ def data_source_update_sink(self) -> Optional[AsyncDataSourceUpdateSink]:
466503
return self._data_source_update_sink
467504

468505
@property
469-
def datasystem_config(self) -> Optional[DataSystemConfig]:
506+
def datasystem_config(self) -> Optional[AsyncDataSystemConfig]:
470507
"""
471508
Configuration for the upcoming enhanced data system design. This is
472509
experimental and should not be set without direction from LaunchDarkly

ldclient/client.py

Lines changed: 6 additions & 138 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,12 @@
44

55
import threading
66
import traceback
7-
from typing import Any, Callable, Dict, List, Mapping, Optional, Tuple
7+
from typing import Any, Callable, List, Optional, Tuple
88
from uuid import uuid4
99

1010
from ldclient.config import Config
1111
from ldclient.context import Context
1212
from ldclient.evaluation import EvaluationDetail, FeatureFlagsState
13-
from ldclient.feature_store import _FeatureStoreDataSetSorter
1413
from ldclient.hook import (
1514
EvaluationSeriesContext,
1615
Hook,
@@ -24,15 +23,7 @@
2423
from ldclient.impl.client_common import secure_mode_hash as _secure_mode_hash
2524
from ldclient.impl.datasource.feature_requester import FeatureRequesterImpl
2625
from ldclient.impl.datasource.polling import PollingUpdateProcessor
27-
from ldclient.impl.datasource.status import (
28-
DataSourceStatusProviderImpl,
29-
DataSourceUpdateSinkImpl
30-
)
3126
from ldclient.impl.datasource.streaming import StreamingUpdateProcessor
32-
from ldclient.impl.datastore.status import (
33-
DataStoreStatusProviderImpl,
34-
DataStoreUpdateSinkImpl
35-
)
3627
from ldclient.impl.datasystem import DataAvailability, DataSystem
3728
from ldclient.impl.datasystem.fdv2 import FDv2
3829
from ldclient.impl.evaluator import Evaluator, error_reason
@@ -43,146 +34,23 @@
4334
from ldclient.impl.events.event_processor import DefaultEventProcessor
4435
from ldclient.impl.events.types import EventFactory
4536
from ldclient.impl.flag_tracker import FlagTrackerImpl
46-
from ldclient.impl.listeners import Listeners
4737
from ldclient.impl.model.feature_flag import FeatureFlag
48-
from ldclient.impl.repeating_task import RepeatingTask
4938
from ldclient.impl.rwlock import ReadWriteLock
5039
from ldclient.impl.stubs import NullEventProcessor, NullUpdateProcessor
5140
from ldclient.impl.util import check_uwsgi, log
5241
from ldclient.interfaces import (
5342
BigSegmentStoreStatusProvider,
5443
DataSourceStatusProvider,
55-
DataStoreStatus,
5644
DataStoreStatusProvider,
57-
DataStoreUpdateSink,
58-
FeatureStore,
59-
FlagTracker,
60-
ReadOnlyStore
45+
FlagTracker
6146
)
6247
from ldclient.migrations import OpTracker, Stage
6348
from ldclient.plugin import EnvironmentMetadata
64-
from ldclient.versioned_data_kind import FEATURES, SEGMENTS, VersionedDataKind
49+
from ldclient.versioned_data_kind import FEATURES, SEGMENTS
6550

6651
from .impl import AnyNum
6752

6853

69-
class _FeatureStoreClientWrapper(FeatureStore):
70-
"""Provides additional behavior that the client requires before or after feature store operations.
71-
Currently this just means sorting the data set for init() and dealing with data store status listeners.
72-
"""
73-
74-
def __init__(self, store: FeatureStore, store_update_sink: DataStoreUpdateSink):
75-
self.store = store
76-
self.__store_update_sink = store_update_sink
77-
self.__monitoring_enabled = self.is_monitoring_enabled()
78-
79-
# Covers the following variables
80-
self.__lock = ReadWriteLock()
81-
self.__last_available = True
82-
self.__poller: Optional[RepeatingTask] = None
83-
84-
def init(self, all_data: Mapping[VersionedDataKind, Mapping[str, Dict[Any, Any]]]):
85-
return self.__wrapper(lambda: self.store.init(_FeatureStoreDataSetSorter.sort_all_collections(all_data)))
86-
87-
def get(self, kind, key, callback):
88-
return self.__wrapper(lambda: self.store.get(kind, key, callback))
89-
90-
def all(self, kind, callback):
91-
return self.__wrapper(lambda: self.store.all(kind, callback))
92-
93-
def delete(self, kind, key, version):
94-
return self.__wrapper(lambda: self.store.delete(kind, key, version))
95-
96-
def upsert(self, kind, item):
97-
return self.__wrapper(lambda: self.store.upsert(kind, item))
98-
99-
@property
100-
def initialized(self) -> bool:
101-
return self.store.initialized
102-
103-
def __wrapper(self, fn: Callable):
104-
try:
105-
return fn()
106-
except BaseException:
107-
if self.__monitoring_enabled:
108-
self.__update_availability(False)
109-
raise
110-
111-
def __update_availability(self, available: bool):
112-
with self.__lock.write():
113-
if available == self.__last_available:
114-
return
115-
self.__last_available = available
116-
117-
status = DataStoreStatus(available, False)
118-
119-
if available:
120-
log.warn("Persistent store is available again")
121-
122-
self.__store_update_sink.update_status(status)
123-
124-
if available:
125-
with self.__lock.write():
126-
if self.__poller is not None:
127-
self.__poller.stop()
128-
self.__poller = None
129-
130-
return
131-
132-
log.warn("Detected persistent store unavailability; updates will be cached until it recovers")
133-
task = RepeatingTask("ldclient.check-availability", 0.5, 0, self.__check_availability)
134-
135-
with self.__lock.write():
136-
self.__poller = task
137-
self.__poller.start()
138-
139-
def __check_availability(self):
140-
try:
141-
if self.store.is_available():
142-
self.__update_availability(True)
143-
except BaseException as e:
144-
log.error("Unexpected error from data store status function: %s", e)
145-
146-
def is_monitoring_enabled(self) -> bool:
147-
"""
148-
This methods determines whether the wrapped store can support enabling monitoring.
149-
150-
The wrapped store must provide a monitoring_enabled method, which must
151-
be true. But this alone is not sufficient.
152-
153-
Because this class wraps all interactions with a provided store, it can
154-
technically "monitor" any store. However, monitoring also requires that
155-
we notify listeners when the store is available again.
156-
157-
We determine this by checking the store's `available?` method, so this
158-
is also a requirement for monitoring support.
159-
160-
These extra checks won't be necessary once `available` becomes a part
161-
of the core interface requirements and this class no longer wraps every
162-
feature store.
163-
"""
164-
165-
if not hasattr(self.store, 'is_monitoring_enabled'):
166-
return False
167-
168-
if not hasattr(self.store, 'is_available'):
169-
return False
170-
171-
monitoring_enabled = getattr(self.store, 'is_monitoring_enabled')
172-
if not callable(monitoring_enabled):
173-
return False
174-
175-
return monitoring_enabled()
176-
177-
178-
def _get_store_item(store, kind: VersionedDataKind, key: str) -> Any:
179-
# This decorator around store.get provides backward compatibility with any custom data
180-
# store implementation that might still be returning a dict, instead of our data model
181-
# classes like FeatureFlag.
182-
item = store.get(kind, key, lambda x: x)
183-
return kind.decode(item) if isinstance(item, dict) else item
184-
185-
18654
class LDClient:
18755
"""The LaunchDarkly SDK client object.
18856
@@ -268,8 +136,8 @@ def __start_up(self, start_wait: float):
268136
self.__big_segment_store_manager = big_segment_store_manager
269137

270138
self._evaluator = Evaluator(
271-
lambda key: _get_store_item(self._data_system.store, FEATURES, key),
272-
lambda key: _get_store_item(self._data_system.store, SEGMENTS, key),
139+
lambda key: self._data_system.store.get(FEATURES, key),
140+
lambda key: self._data_system.store.get(SEGMENTS, key),
273141
lambda key: big_segment_store_manager.get_user_membership(key),
274142
log,
275143
)
@@ -554,7 +422,7 @@ def _evaluate_internal(self, key: str, context: Context, default: Any, event_fac
554422
return EvaluationDetail(default, None, error_reason('USER_NOT_SPECIFIED')), None
555423

556424
try:
557-
flag = _get_store_item(self._data_system.store, FEATURES, key)
425+
flag = self._data_system.store.get(FEATURES, key)
558426
except Exception as e:
559427
log.error("Unexpected error while retrieving feature flag \"%s\": %s" % (key, repr(e)))
560428
log.debug(traceback.format_exc())

ldclient/config.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
Note that the same class can also be imported from the ``ldclient.client`` submodule.
55
"""
66

7+
import copy
78
import warnings
89
from dataclasses import dataclass
910
from threading import Event
@@ -472,6 +473,25 @@ def copy_with_new_sdk_key(self, new_sdk_key: str) -> 'Config':
472473
big_segments=self.__big_segments,
473474
)
474475

476+
def with_wrapper_information(self, wrapper_name: Optional[str], wrapper_version: Optional[str] = None) -> 'Config':
477+
"""Returns a new ``Config`` instance that is the same as this one, except for having different wrapper information.
478+
479+
This is intended for use by wrapper libraries, such as the LaunchDarkly OpenFeature providers, which need to
480+
identify themselves rather than the application that configured the client.
481+
482+
The new instance is a shallow copy: it shares the objects the original configuration references, such as the
483+
feature store, the logger, and the HTTP configuration. Mutating one of those objects affects both
484+
configurations.
485+
486+
:param wrapper_name: an identifying name for the wrapper being used; see :py:attr:`~wrapper_name`
487+
:param wrapper_version: the version of the wrapper being used; see :py:attr:`~wrapper_version`
488+
"""
489+
updated = copy.copy(self)
490+
updated.__wrapper_name = wrapper_name
491+
updated.__wrapper_version = wrapper_version
492+
493+
return updated
494+
475495
# for internal use only - probably should be part of the client logic
476496
def get_default(self, key, default):
477497
return default if key not in self.__defaults else self.__defaults[key]

ldclient/impl/datasourcev2/async_polling.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -242,9 +242,12 @@ async def close(self) -> None:
242242
await self._http.close()
243243

244244

245-
class AsyncPollingDataSourceBuilder(DataSourceBuilder):
245+
class AsyncPollingDataSourceBuilder(DataSourceBuilder[AsyncPollingDataSource]):
246246
"""
247247
Builder for a AsyncPollingDataSource.
248+
249+
The built polling data source implements both :class:`AsyncInitializer` and
250+
:class:`AsyncSynchronizer`, so this builder can be used in either role.
248251
"""
249252

250253
def __init__(self):
@@ -298,7 +301,7 @@ def build(self, config: DataSourceBuilderConfig) -> AsyncPollingDataSource:
298301
)
299302

300303

301-
class AsyncFallbackToFDv1PollingDataSourceBuilder(DataSourceBuilder):
304+
class AsyncFallbackToFDv1PollingDataSourceBuilder(DataSourceBuilder[AsyncPollingDataSource]):
302305
"""
303306
Builder for a AsyncPollingDataSource that falls back to Flag Delivery v1.
304307
"""

ldclient/impl/datasourcev2/async_streaming.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -284,7 +284,7 @@ async def _handle_error(self, error: Exception, envid: Optional[str]) -> Tuple[O
284284
return (decision.update, decision.should_continue)
285285

286286

287-
class AsyncStreamingDataSourceBuilder(DataSourceBuilder):
287+
class AsyncStreamingDataSourceBuilder(DataSourceBuilder[AsyncStreamingDataSource]):
288288
"""
289289
Builder for a AsyncStreamingDataSource.
290290
"""

ldclient/impl/datastore/status.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
)
1313

1414
if TYPE_CHECKING:
15-
from ldclient.client import _FeatureStoreClientWrapper
15+
from ldclient.impl.datasystem.fdv1 import _FeatureStoreClientWrapper
1616

1717

1818
class DataStoreUpdateSinkImpl(DataStoreUpdateSink):

0 commit comments

Comments
 (0)