Skip to content

Commit f733d07

Browse files
committed
feat: Add data source status provider support (#228)
The client instance will now provide access to a `data_source_status_provider`. This provider allows developers to retrieve the status of the SDK on demand, or through registered listeners.
1 parent 99aafd5 commit f733d07

14 files changed

+859
-55
lines changed

ldclient/client.py

+22-1
Original file line numberDiff line numberDiff line change
@@ -20,14 +20,16 @@
2020
from ldclient.impl.datasource.feature_requester import FeatureRequesterImpl
2121
from ldclient.impl.datasource.polling import PollingUpdateProcessor
2222
from ldclient.impl.datasource.streaming import StreamingUpdateProcessor
23+
from ldclient.impl.datasource.status import DataSourceUpdateSinkImpl, DataSourceStatusProviderImpl
2324
from ldclient.impl.evaluator import Evaluator, error_reason
2425
from ldclient.impl.events.diagnostics import create_diagnostic_id, _DiagnosticAccumulator
2526
from ldclient.impl.events.event_processor import DefaultEventProcessor
2627
from ldclient.impl.events.types import EventFactory
2728
from ldclient.impl.model.feature_flag import FeatureFlag
29+
from ldclient.impl.listeners import Listeners
2830
from ldclient.impl.stubs import NullEventProcessor, NullUpdateProcessor
2931
from ldclient.impl.util import check_uwsgi, log
30-
from ldclient.interfaces import BigSegmentStoreStatusProvider, FeatureRequester, FeatureStore
32+
from ldclient.interfaces import BigSegmentStoreStatusProvider, DataSourceStatusProvider, FeatureRequester, FeatureStore
3133
from ldclient.versioned_data_kind import FEATURES, SEGMENTS, VersionedDataKind
3234
from ldclient.feature_store import FeatureStore
3335
from ldclient.migrations import Stage, OpTracker
@@ -100,6 +102,10 @@ def __init__(self, config: Config, start_wait: float=5):
100102
self._event_factory_with_reasons = EventFactory(True)
101103

102104
store = _FeatureStoreClientWrapper(self._config.feature_store)
105+
106+
listeners = Listeners()
107+
self._config._data_source_update_sink = DataSourceUpdateSinkImpl(store, listeners)
108+
self.__data_source_status_provider = DataSourceStatusProviderImpl(listeners, self._config._data_source_update_sink)
103109
self._store = store # type: FeatureStore
104110

105111
big_segment_store_manager = BigSegmentStoreManager(self._config.big_segments)
@@ -489,5 +495,20 @@ def big_segment_store_status_provider(self) -> BigSegmentStoreStatusProvider:
489495
"""
490496
return self.__big_segment_store_manager.status_provider
491497

498+
@property
499+
def data_source_status_provider(self) -> DataSourceStatusProvider:
500+
"""
501+
Returns an interface for tracking the status of the data source.
502+
503+
The data source is the mechanism that the SDK uses to get feature flag configurations, such
504+
as a streaming connection (the default) or poll requests. The
505+
:class:`ldclient.interfaces.DataSourceStatusProvider` has methods for checking whether the
506+
data source is (as far as the SDK knows) currently operational and tracking changes in this
507+
status.
508+
509+
:return: The data source status provider
510+
"""
511+
return self.__data_source_status_provider
512+
492513

493514
__all__ = ['LDClient', 'Config']

ldclient/config.py

+16-1
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88

99
from ldclient.feature_store import InMemoryFeatureStore
1010
from ldclient.impl.util import log, validate_application_info
11-
from ldclient.interfaces import BigSegmentStore, EventProcessor, FeatureStore, UpdateProcessor
11+
from ldclient.interfaces import BigSegmentStore, EventProcessor, FeatureStore, UpdateProcessor, DataSourceUpdateSink
1212

1313
GET_LATEST_FEATURES_PATH = '/sdk/latest-flags'
1414
STREAM_FLAGS_PATH = '/flags'
@@ -269,6 +269,7 @@ def __init__(self,
269269
self.__http = http
270270
self.__big_segments = BigSegmentsConfig() if not big_segments else big_segments
271271
self.__application = validate_application_info(application or {}, log)
272+
self._data_source_update_sink: Optional[DataSourceUpdateSink] = None
272273

273274
def copy_with_new_sdk_key(self, new_sdk_key: str) -> 'Config':
274275
"""Returns a new ``Config`` instance that is the same as this one, except for having a different SDK key.
@@ -440,6 +441,20 @@ def application(self) -> dict:
440441
"""
441442
return self.__application
442443

444+
@property
445+
def data_source_update_sink(self) -> Optional[DataSourceUpdateSink]:
446+
"""
447+
Returns the component that allows a data source to push data into the SDK.
448+
449+
This property should only be set by the SDK. Long term access of this
450+
property is not supported; it is temporarily being exposed to maintain
451+
backwards compatibility while the SDK structure is updated.
452+
453+
Custom data source implementations should integrate with this sink if
454+
they want to provide support for data source status listeners.
455+
"""
456+
return self._data_source_update_sink
457+
443458
def _validate(self):
444459
if self.offline is False and self.sdk_key is None or self.sdk_key == '':
445460
log.warning("Missing or blank sdk_key.")

ldclient/impl/datasource/polling.py

+61-7
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,16 @@
88
from ldclient.config import Config
99
from ldclient.impl.repeating_task import RepeatingTask
1010
from ldclient.impl.util import UnsuccessfulResponseException, http_error_message, is_http_error_recoverable, log
11-
from ldclient.interfaces import FeatureRequester, FeatureStore, UpdateProcessor
11+
from ldclient.interfaces import FeatureRequester, FeatureStore, UpdateProcessor, DataSourceUpdateSink, DataSourceErrorInfo, DataSourceErrorKind, DataSourceState
12+
13+
import time
14+
from typing import Optional
1215

1316

1417
class PollingUpdateProcessor(UpdateProcessor):
1518
def __init__(self, config: Config, requester: FeatureRequester, store: FeatureStore, ready: Event):
1619
self._config = config
20+
self._data_source_update_sink: Optional[DataSourceUpdateSink] = config.data_source_update_sink
1721
self._requester = requester
1822
self._store = store
1923
self._ready = ready
@@ -27,24 +31,74 @@ def initialized(self):
2731
return self._ready.is_set() is True and self._store.initialized is True
2832

2933
def stop(self):
34+
self.__stop_with_error_info(None)
35+
36+
def __stop_with_error_info(self, error: Optional[DataSourceErrorInfo]):
3037
log.info("Stopping PollingUpdateProcessor")
3138
self._task.stop()
3239

40+
if self._data_source_update_sink is None:
41+
return
42+
43+
self._data_source_update_sink.update_status(
44+
DataSourceState.OFF,
45+
error
46+
)
47+
48+
def _sink_or_store(self):
49+
"""
50+
The original implementation of this class relied on the feature store
51+
directly, which we are trying to move away from. Customers who might have
52+
instantiated this directly for some reason wouldn't know they have to set
53+
the config's sink manually, so we have to fall back to the store if the
54+
sink isn't present.
55+
56+
The next major release should be able to simplify this structure and
57+
remove the need for fall back to the data store because the update sink
58+
should always be present.
59+
"""
60+
if self._data_source_update_sink is None:
61+
return self._store
62+
63+
return self._data_source_update_sink
64+
3365
def _poll(self):
3466
try:
3567
all_data = self._requester.get_all_data()
36-
self._store.init(all_data)
68+
self._sink_or_store().init(all_data)
3769
if not self._ready.is_set() and self._store.initialized:
3870
log.info("PollingUpdateProcessor initialized ok")
3971
self._ready.set()
72+
73+
if self._data_source_update_sink is not None:
74+
self._data_source_update_sink.update_status(DataSourceState.VALID, None)
4075
except UnsuccessfulResponseException as e:
76+
error_info = DataSourceErrorInfo(
77+
DataSourceErrorKind.ERROR_RESPONSE,
78+
e.status,
79+
time.time(),
80+
str(e)
81+
)
82+
4183
http_error_message_result = http_error_message(e.status, "polling request")
42-
if is_http_error_recoverable(e.status):
43-
log.warning(http_error_message_result)
44-
else:
84+
if not is_http_error_recoverable(e.status):
4585
log.error(http_error_message_result)
46-
self._ready.set() # if client is initializing, make it stop waiting; has no effect if already inited
47-
self.stop()
86+
self._ready.set() # if client is initializing, make it stop waiting; has no effect if already inited
87+
self.__stop_with_error_info(error_info)
88+
else:
89+
log.warning(http_error_message_result)
90+
91+
if self._data_source_update_sink is not None:
92+
self._data_source_update_sink.update_status(
93+
DataSourceState.INTERRUPTED,
94+
error_info
95+
)
4896
except Exception as e:
4997
log.exception(
5098
'Error: Exception encountered when updating flags. %s' % e)
99+
100+
if self._data_source_update_sink is not None:
101+
self._data_source_update_sink.update_status(
102+
DataSourceState.INTERRUPTED,
103+
DataSourceErrorInfo(DataSourceErrorKind.UNKNOWN, 0, time.time, str(e))
104+
)

ldclient/impl/datasource/status.py

+92
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
from ldclient.impl.listeners import Listeners
2+
from ldclient.interfaces import DataSourceStatusProvider, DataSourceUpdateSink, DataSourceStatus, FeatureStore, DataSourceState, DataSourceErrorInfo, DataSourceErrorKind
3+
from ldclient.impl.rwlock import ReadWriteLock
4+
from ldclient.versioned_data_kind import VersionedDataKind
5+
6+
import time
7+
from typing import Callable, Mapping, Optional
8+
9+
10+
class DataSourceUpdateSinkImpl(DataSourceUpdateSink):
11+
def __init__(self, store: FeatureStore, listeners: Listeners):
12+
self.__store = store
13+
self.__listeners = listeners
14+
15+
self.__lock = ReadWriteLock()
16+
self.__status = DataSourceStatus(
17+
DataSourceState.INITIALIZING,
18+
time.time(),
19+
None
20+
)
21+
22+
@property
23+
def status(self) -> DataSourceStatus:
24+
try:
25+
self.__lock.rlock()
26+
return self.__status
27+
finally:
28+
self.__lock.runlock()
29+
30+
def init(self, all_data: Mapping[VersionedDataKind, Mapping[str, dict]]):
31+
self.__monitor_store_update(lambda: self.__store.init(all_data))
32+
33+
def upsert(self, kind: VersionedDataKind, item: dict):
34+
self.__monitor_store_update(lambda: self.__store.upsert(kind, item))
35+
36+
def delete(self, kind: VersionedDataKind, key: str, version: int):
37+
self.__monitor_store_update(lambda: self.__store.delete(kind, key, version))
38+
39+
def update_status(self, new_state: DataSourceState, new_error: Optional[DataSourceErrorInfo]):
40+
status_to_broadcast = None
41+
42+
try:
43+
self.__lock.lock()
44+
old_status = self.__status
45+
46+
if new_state == DataSourceState.INTERRUPTED and old_status.state == DataSourceState.INITIALIZING:
47+
new_state = DataSourceState.INITIALIZING
48+
49+
if new_state == old_status.state and new_error is None:
50+
return
51+
52+
self.__status = DataSourceStatus(
53+
new_state,
54+
self.__status.since if new_state == self.__status.state else time.time(),
55+
self.__status.error if new_error is None else new_error
56+
)
57+
58+
status_to_broadcast = self.__status
59+
finally:
60+
self.__lock.unlock()
61+
62+
if status_to_broadcast is not None:
63+
self.__listeners.notify(status_to_broadcast)
64+
65+
def __monitor_store_update(self, fn: Callable[[], None]):
66+
try:
67+
fn()
68+
except Exception as e:
69+
error_info = DataSourceErrorInfo(
70+
DataSourceErrorKind.STORE_ERROR,
71+
0,
72+
time.time(),
73+
str(e)
74+
)
75+
self.update_status(DataSourceState.INTERRUPTED, error_info)
76+
raise
77+
78+
79+
class DataSourceStatusProviderImpl(DataSourceStatusProvider):
80+
def __init__(self, listeners: Listeners, updates_sink: DataSourceUpdateSinkImpl):
81+
self.__listeners = listeners
82+
self.__updates_sink = updates_sink
83+
84+
@property
85+
def status(self) -> DataSourceStatus:
86+
return self.__updates_sink.status
87+
88+
def add_listener(self, listener: Callable[[DataSourceStatus], None]):
89+
self.__listeners.add(listener)
90+
91+
def remove_listener(self, listener: Callable[[DataSourceStatus], None]):
92+
self.__listeners.remove(listener)

0 commit comments

Comments
 (0)