Skip to content

Commit 4df1762

Browse files
committed
feat: Introduce flag change tracker api (#229)
The client instance will now provide access to a `flag_tracker`. This tracker allows developers to be notified when a flag configuration changes (or optionally when the /value/ of a flag changes for a particular context).
1 parent f733d07 commit 4df1762

12 files changed

+838
-30
lines changed

ldclient/client.py

+21-4
Original file line numberDiff line numberDiff line change
@@ -29,10 +29,11 @@
2929
from ldclient.impl.listeners import Listeners
3030
from ldclient.impl.stubs import NullEventProcessor, NullUpdateProcessor
3131
from ldclient.impl.util import check_uwsgi, log
32-
from ldclient.interfaces import BigSegmentStoreStatusProvider, DataSourceStatusProvider, FeatureRequester, FeatureStore
32+
from ldclient.interfaces import BigSegmentStoreStatusProvider, DataSourceStatusProvider, FeatureRequester, FeatureStore, FlagTracker
3333
from ldclient.versioned_data_kind import FEATURES, SEGMENTS, VersionedDataKind
3434
from ldclient.feature_store import FeatureStore
3535
from ldclient.migrations import Stage, OpTracker
36+
from ldclient.impl.flag_tracker import FlagTrackerImpl
3637

3738
from threading import Lock
3839

@@ -103,9 +104,13 @@ def __init__(self, config: Config, start_wait: float=5):
103104

104105
store = _FeatureStoreClientWrapper(self._config.feature_store)
105106

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)
107+
data_source_listeners = Listeners()
108+
flag_change_listeners = Listeners()
109+
110+
self.__flag_tracker = FlagTrackerImpl(flag_change_listeners, lambda key, context: self.variation(key, context, None))
111+
112+
self._config._data_source_update_sink = DataSourceUpdateSinkImpl(store, data_source_listeners, flag_change_listeners)
113+
self.__data_source_status_provider = DataSourceStatusProviderImpl(data_source_listeners, self._config._data_source_update_sink)
109114
self._store = store # type: FeatureStore
110115

111116
big_segment_store_manager = BigSegmentStoreManager(self._config.big_segments)
@@ -510,5 +515,17 @@ def data_source_status_provider(self) -> DataSourceStatusProvider:
510515
"""
511516
return self.__data_source_status_provider
512517

518+
@property
519+
def flag_tracker(self) -> FlagTracker:
520+
"""
521+
Returns an interface for tracking changes in feature flag configurations.
522+
523+
The :class:`ldclient.interfaces.FlagTracker` contains methods for
524+
requesting notifications about feature flag changes using an event
525+
listener model.
526+
"""
527+
return self.__flag_tracker
528+
529+
513530

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

ldclient/impl/datasource/status.py

+76-6
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,21 @@
1+
from ldclient.versioned_data_kind import FEATURES, SEGMENTS
2+
from ldclient.impl.dependency_tracker import DependencyTracker
13
from ldclient.impl.listeners import Listeners
2-
from ldclient.interfaces import DataSourceStatusProvider, DataSourceUpdateSink, DataSourceStatus, FeatureStore, DataSourceState, DataSourceErrorInfo, DataSourceErrorKind
4+
from ldclient.interfaces import DataSourceStatusProvider, DataSourceUpdateSink, DataSourceStatus, FeatureStore, DataSourceState, DataSourceErrorInfo, DataSourceErrorKind, FlagChange
35
from ldclient.impl.rwlock import ReadWriteLock
46
from ldclient.versioned_data_kind import VersionedDataKind
7+
from ldclient.impl.dependency_tracker import KindAndKey
58

69
import time
7-
from typing import Callable, Mapping, Optional
10+
from typing import Callable, Mapping, Optional, Set
811

912

1013
class DataSourceUpdateSinkImpl(DataSourceUpdateSink):
11-
def __init__(self, store: FeatureStore, listeners: Listeners):
14+
def __init__(self, store: FeatureStore, status_listeners: Listeners, flag_change_listeners: Listeners):
1215
self.__store = store
13-
self.__listeners = listeners
16+
self.__status_listeners = status_listeners
17+
self.__flag_change_listeners = flag_change_listeners
18+
self.__tracker = DependencyTracker()
1419

1520
self.__lock = ReadWriteLock()
1621
self.__status = DataSourceStatus(
@@ -28,13 +33,38 @@ def status(self) -> DataSourceStatus:
2833
self.__lock.runlock()
2934

3035
def init(self, all_data: Mapping[VersionedDataKind, Mapping[str, dict]]):
31-
self.__monitor_store_update(lambda: self.__store.init(all_data))
36+
old_data = None
37+
38+
def init_store():
39+
nonlocal old_data
40+
if self.__flag_change_listeners.has_listeners():
41+
old_data = {}
42+
for kind in [FEATURES, SEGMENTS]:
43+
old_data[kind] = self.__store.all(kind, lambda x: x)
44+
45+
self.__store.init(all_data)
46+
47+
self.__monitor_store_update(init_store)
48+
self.__reset_tracker_with_new_data(all_data)
49+
50+
if old_data is None:
51+
return
52+
53+
self.__send_change_events(
54+
self.__compute_changed_items_for_full_data_set(old_data, all_data)
55+
)
3256

3357
def upsert(self, kind: VersionedDataKind, item: dict):
3458
self.__monitor_store_update(lambda: self.__store.upsert(kind, item))
3559

60+
# TODO(sc-212471): We only want to do this if the store successfully
61+
# updates the record.
62+
key = item.get('key', '')
63+
self.__update_dependency_for_single_item(kind, key, item)
64+
3665
def delete(self, kind: VersionedDataKind, key: str, version: int):
3766
self.__monitor_store_update(lambda: self.__store.delete(kind, key, version))
67+
self.__update_dependency_for_single_item(kind, key, None)
3868

3969
def update_status(self, new_state: DataSourceState, new_error: Optional[DataSourceErrorInfo]):
4070
status_to_broadcast = None
@@ -60,7 +90,7 @@ def update_status(self, new_state: DataSourceState, new_error: Optional[DataSour
6090
self.__lock.unlock()
6191

6292
if status_to_broadcast is not None:
63-
self.__listeners.notify(status_to_broadcast)
93+
self.__status_listeners.notify(status_to_broadcast)
6494

6595
def __monitor_store_update(self, fn: Callable[[], None]):
6696
try:
@@ -75,6 +105,46 @@ def __monitor_store_update(self, fn: Callable[[], None]):
75105
self.update_status(DataSourceState.INTERRUPTED, error_info)
76106
raise
77107

108+
def __update_dependency_for_single_item(self, kind: VersionedDataKind, key: str, item: Optional[dict]):
109+
self.__tracker.update_dependencies_from(kind, key, item)
110+
if self.__flag_change_listeners.has_listeners():
111+
affected_items: Set[KindAndKey] = set()
112+
self.__tracker.add_affected_items(affected_items, KindAndKey(kind=kind, key=key))
113+
self.__send_change_events(affected_items)
114+
115+
def __reset_tracker_with_new_data(self, all_data: Mapping[VersionedDataKind, Mapping[str, dict]]):
116+
self.__tracker.reset()
117+
118+
for kind, items in all_data.items():
119+
for key, item in items.items():
120+
self.__tracker.update_dependencies_from(kind, key, item)
121+
122+
def __send_change_events(self, affected_items: Set[KindAndKey]):
123+
for item in affected_items:
124+
if item.kind == FEATURES:
125+
self.__flag_change_listeners.notify(FlagChange(item.key))
126+
127+
def __compute_changed_items_for_full_data_set(self, old_data: Mapping[VersionedDataKind, Mapping[str, dict]], new_data: Mapping[VersionedDataKind, Mapping[str, dict]]):
128+
affected_items: Set[KindAndKey] = set()
129+
130+
for kind in [FEATURES, SEGMENTS]:
131+
old_items = old_data.get(kind, {})
132+
new_items = new_data.get(kind, {})
133+
134+
keys: Set[str] = set()
135+
136+
for key in keys.union(old_items.keys(), new_items.keys()):
137+
old_item = old_items.get(key)
138+
new_item = new_items.get(key)
139+
140+
if old_item is None and new_item is None:
141+
continue
142+
143+
if old_item is None or new_item is None or old_item['version'] < new_item['version']:
144+
self.__tracker.add_affected_items(affected_items, KindAndKey(kind=kind, key=key))
145+
146+
return affected_items
147+
78148

79149
class DataSourceStatusProviderImpl(DataSourceStatusProvider):
80150
def __init__(self, listeners: Listeners, updates_sink: DataSourceUpdateSinkImpl):

ldclient/impl/dependency_tracker.py

+119
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
from ldclient.impl.model.feature_flag import FeatureFlag
2+
from ldclient.impl.model.segment import Segment
3+
from ldclient.impl.model.clause import Clause
4+
from ldclient.versioned_data_kind import VersionedDataKind, SEGMENTS, FEATURES
5+
6+
from typing import Set, List, Dict, NamedTuple, Union, Optional
7+
8+
9+
class KindAndKey(NamedTuple):
10+
kind: VersionedDataKind
11+
key: str
12+
13+
14+
class DependencyTracker:
15+
"""
16+
The DependencyTracker is responsible for tracking both up and downstream
17+
dependency relationships. Managing a bi-directional mapping allows us to
18+
more easily perform updates to the tracker, and to determine affected items
19+
when a downstream item is modified.
20+
"""
21+
22+
def __init__(self):
23+
self.__children: Dict[KindAndKey, Set[KindAndKey]] = {}
24+
self.__parents: Dict[KindAndKey, Set[KindAndKey]] = {}
25+
26+
def update_dependencies_from(self, from_kind: VersionedDataKind, from_key: str, from_item: Optional[Union[dict, FeatureFlag, Segment]]):
27+
"""
28+
Updates the dependency graph when an item has changed.
29+
30+
:param from_kind: the changed item's kind
31+
:param from_key: the changed item's key
32+
:param from_item: the changed item
33+
34+
"""
35+
from_what = KindAndKey(kind=from_kind, key=from_key)
36+
updated_dependencies = DependencyTracker.compute_dependencies_from(from_kind, from_item)
37+
38+
old_children_set = self.__children.get(from_what)
39+
40+
if old_children_set is not None:
41+
for kind_and_key in old_children_set:
42+
parents_of_this_old_dep = self.__parents.get(kind_and_key, set())
43+
if from_what in parents_of_this_old_dep:
44+
parents_of_this_old_dep.remove(from_what)
45+
46+
self.__children[from_what] = updated_dependencies
47+
for kind_and_key in updated_dependencies:
48+
parents_of_this_new_dep = self.__parents.get(kind_and_key)
49+
if parents_of_this_new_dep is None:
50+
parents_of_this_new_dep = set()
51+
self.__parents[kind_and_key] = parents_of_this_new_dep
52+
53+
parents_of_this_new_dep.add(from_what)
54+
55+
def add_affected_items(self, items_out: Set[KindAndKey], initial_modified_item: KindAndKey):
56+
"""
57+
58+
Populates the given set with the union of the initial item and all items that directly or indirectly
59+
depend on it (based on the current state of the dependency graph).
60+
61+
@param items_out [Set]
62+
@param initial_modified_item [Object]
63+
64+
"""
65+
66+
if initial_modified_item in items_out:
67+
return
68+
69+
items_out.add(initial_modified_item)
70+
71+
parents = self.__parents.get(initial_modified_item)
72+
if parents is None:
73+
return
74+
75+
for parent in parents:
76+
self.add_affected_items(items_out, parent)
77+
78+
def reset(self):
79+
"""
80+
Clear any tracked dependencies and reset the tracking state to a clean slate.
81+
"""
82+
self.__children.clear()
83+
self.__parents.clear()
84+
85+
@staticmethod
86+
def compute_dependencies_from(from_kind: VersionedDataKind, from_item: Optional[Union[dict, FeatureFlag, Segment]]) -> Set[KindAndKey]:
87+
"""
88+
@param from_kind [String]
89+
@param from_item [LaunchDarkly::Impl::Model::FeatureFlag, LaunchDarkly::Impl::Model::Segment]
90+
@return [Set]
91+
"""
92+
if from_item is None:
93+
return set()
94+
95+
from_item = from_kind.decode(from_item) if isinstance(from_item, dict) else from_item
96+
97+
if from_kind == FEATURES and isinstance(from_item, FeatureFlag):
98+
prereq_keys = [KindAndKey(kind=from_kind, key=p.key) for p in from_item.prerequisites]
99+
segment_keys = [kindAndKey for rule in from_item.rules for kindAndKey in DependencyTracker.segment_keys_from_clauses(rule.clauses)]
100+
101+
results = set(prereq_keys)
102+
results.update(segment_keys)
103+
104+
return results
105+
elif from_kind == SEGMENTS and isinstance(from_item, Segment):
106+
kind_and_keys = [key for rule in from_item.rules for key in DependencyTracker.segment_keys_from_clauses(rule.clauses)]
107+
return set(kind_and_keys)
108+
else:
109+
return set()
110+
111+
@staticmethod
112+
def segment_keys_from_clauses(clauses: List[Clause]) -> List[KindAndKey]:
113+
results = []
114+
for clause in clauses:
115+
if clause.op == 'segmentMatch':
116+
pairs = [KindAndKey(kind=SEGMENTS, key=value) for value in clause.values]
117+
results.extend(pairs)
118+
119+
return results

ldclient/impl/flag_tracker.py

+50
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
from ldclient.interfaces import FlagTracker, FlagChange, FlagValueChange
2+
from ldclient.impl.listeners import Listeners
3+
from ldclient.context import Context
4+
from ldclient.impl.rwlock import ReadWriteLock
5+
6+
from typing import Callable
7+
8+
9+
class FlagValueChangeListener:
10+
def __init__(self, key: str, context: Context, listener: Callable[[FlagValueChange], None], eval_fn: Callable):
11+
self.__key = key
12+
self.__context = context
13+
self.__listener = listener
14+
self.__eval_fn = eval_fn
15+
16+
self.__lock = ReadWriteLock()
17+
self.__value = eval_fn(key, context)
18+
19+
def __call__(self, flag_change: FlagChange):
20+
if flag_change.key != self.__key:
21+
return
22+
23+
new_value = self.__eval_fn(self.__key, self.__context)
24+
25+
self.__lock.lock()
26+
old_value, self.__value = self.__value, new_value
27+
self.__lock.unlock()
28+
29+
if new_value == old_value:
30+
return
31+
32+
self.__listener(FlagValueChange(self.__key, old_value, new_value))
33+
34+
35+
class FlagTrackerImpl(FlagTracker):
36+
def __init__(self, listeners: Listeners, eval_fn: Callable):
37+
self.__listeners = listeners
38+
self.__eval_fn = eval_fn
39+
40+
def add_listener(self, listener: Callable[[FlagChange], None]):
41+
self.__listeners.add(listener)
42+
43+
def remove_listener(self, listener: Callable[[FlagChange], None]):
44+
self.__listeners.remove(listener)
45+
46+
def add_flag_value_change_listener(self, key: str, context: Context, fn: Callable[[FlagValueChange], None]) -> Callable[[FlagChange], None]:
47+
listener = FlagValueChangeListener(key, context, fn, self.__eval_fn)
48+
self.add_listener(listener)
49+
50+
return listener

ldclient/impl/listeners.py

+10-4
Original file line numberDiff line numberDiff line change
@@ -3,26 +3,32 @@
33
from threading import RLock
44
from typing import Any, Callable
55

6+
67
class Listeners:
78
"""
89
Simple abstraction for a list of callbacks that can receive a single value. Callbacks are
910
done synchronously on the caller's thread.
1011
"""
12+
1113
def __init__(self):
1214
self.__listeners = []
1315
self.__lock = RLock()
14-
16+
17+
def has_listeners(self) -> bool:
18+
with self.__lock:
19+
return len(self.__listeners) > 0
20+
1521
def add(self, listener: Callable):
1622
with self.__lock:
1723
self.__listeners.append(listener)
18-
24+
1925
def remove(self, listener: Callable):
2026
with self.__lock:
2127
try:
2228
self.__listeners.remove(listener)
2329
except ValueError:
24-
pass # removing a listener that wasn't in the list is a no-op
25-
30+
pass # removing a listener that wasn't in the list is a no-op
31+
2632
def notify(self, value: Any):
2733
with self.__lock:
2834
listeners_copy = self.__listeners.copy()

0 commit comments

Comments
 (0)