Skip to content

Commit 7adcd08

Browse files
authored
feat!: Require feature stores to report whether a write was applied (#554)
fix: Prevent flag change listeners from firing for writes the store rejects
1 parent ccb456f commit 7adcd08

13 files changed

Lines changed: 94 additions & 57 deletions

‎ldclient/feature_store.py‎

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -119,16 +119,17 @@ def init(self, all_data):
119119
log.debug("Initialized '%s' store with %d items", k.namespace, len(all_data[k]))
120120

121121
# noinspection PyShadowingNames
122-
def delete(self, kind, key: str, version: int):
122+
def delete(self, kind, key: str, version: int) -> bool:
123123
""" """
124124
with self._lock.write():
125125
itemsOfKind = self._items[kind]
126126
i = itemsOfKind.get(key)
127127
if i is None or i['version'] < version:
128-
i = {'deleted': True, 'version': version}
129-
itemsOfKind[key] = i
128+
itemsOfKind[key] = {'deleted': True, 'version': version}
129+
return True
130+
return False
130131

131-
def upsert(self, kind, item):
132+
def upsert(self, kind, item) -> bool:
132133
""" """
133134
decoded_item = kind.decode(item)
134135
key = item['key']
@@ -138,6 +139,8 @@ def upsert(self, kind, item):
138139
if i is None or i['version'] < item['version']:
139140
itemsOfKind[key] = decoded_item
140141
log.debug("Updated %s in '%s' to version %d", key, kind.namespace, item['version'])
142+
return True
143+
return False
141144

142145
@property
143146
def initialized(self) -> bool:

‎ldclient/feature_store_helpers.py‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -230,16 +230,19 @@ def all(self, kind, callback=lambda x: x):
230230
encoded_items = self._core.get_all_internal(kind)
231231
return callback(self._cache_put_all(kind, encoded_items))
232232

233-
def delete(self, kind, key, version):
233+
def delete(self, kind, key, version) -> bool:
234234
""" """
235235
deleted_item = {"key": key, "version": version, "deleted": True}
236-
self.upsert(kind, deleted_item)
236+
return self.upsert(kind, deleted_item)
237237

238-
def upsert(self, kind, encoded_item):
238+
def upsert(self, kind, encoded_item) -> bool:
239239
""" """
240240
encoded_item = _ensure_encoded(kind, encoded_item)
241241
new_state = self._core.upsert_internal(kind, encoded_item)
242242
self._cache_put_upsert(kind, new_state)
243+
# The core returns the item that we passed in if it applied the write. It returns the
244+
# existing item if the version check rejected the write. Identity thus shows the result.
245+
return new_state is encoded_item
243246

244247
@property
245248
def initialized(self) -> bool:

‎ldclient/impl/datasource/status.py‎

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import time
2-
from typing import Callable, Mapping, Optional, Protocol, Set
2+
from typing import Any, Callable, Mapping, Optional, Protocol, Set
33

44
from ldclient.impl.dependency_tracker import DependencyTracker, KindAndKey
55
from ldclient.impl.listeners import Listeners
@@ -63,16 +63,20 @@ def init_store():
6363
self.__send_change_events(self.__compute_changed_items_for_full_data_set(old_data, all_data))
6464

6565
def upsert(self, kind: VersionedDataKind, item: dict):
66-
self.__monitor_store_update(lambda: self.__store.upsert(kind, item))
66+
updated = self.__monitor_store_update(lambda: self.__store.upsert(kind, item))
6767

68-
# TODO(SDK-62): We only want to do this if the store successfully wrote the record,
69-
# which requires upsert to return a bool result (breaking change).
70-
key = item.get('key', '')
71-
self.__update_dependency_for_single_item(kind, key, item)
68+
# Update dependencies and send change events only if the store applied the write.
69+
# A rejected write does not change the stored data.
70+
if updated:
71+
key = item.get('key', '')
72+
self.__update_dependency_for_single_item(kind, key, item)
7273

7374
def delete(self, kind: VersionedDataKind, key: str, version: int):
74-
self.__monitor_store_update(lambda: self.__store.delete(kind, key, version))
75-
self.__update_dependency_for_single_item(kind, key, None)
75+
deleted = self.__monitor_store_update(lambda: self.__store.delete(kind, key, version))
76+
77+
# Update dependencies and send change events only if the store applied the delete.
78+
if deleted:
79+
self.__update_dependency_for_single_item(kind, key, None)
7680

7781
def update_status(self, new_state: DataSourceState, new_error: Optional[DataSourceErrorInfo]):
7882
status_to_broadcast = None
@@ -98,9 +102,9 @@ def update_status(self, new_state: DataSourceState, new_error: Optional[DataSour
98102
if status_to_broadcast is not None:
99103
self.__status_listeners.notify(status_to_broadcast)
100104

101-
def __monitor_store_update(self, fn: Callable[[], None]):
105+
def __monitor_store_update(self, fn: Callable[[], Any]) -> Any:
102106
try:
103-
fn()
107+
return fn()
104108
except Exception as e:
105109
error_info = DataSourceErrorInfo(DataSourceErrorKind.STORE_ERROR, 0, time.time(), str(e))
106110
self.update_status(DataSourceState.INTERRUPTED, error_info)

‎ldclient/impl/datasystem/fdv1.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -61,10 +61,10 @@ def get(self, kind, key, callback):
6161
def all(self, kind, callback):
6262
return self.__wrapper(lambda: self.store.all(kind, callback))
6363

64-
def delete(self, kind, key, version):
64+
def delete(self, kind, key, version) -> bool:
6565
return self.__wrapper(lambda: self.store.delete(kind, key, version))
6666

67-
def upsert(self, kind, item):
67+
def upsert(self, kind, item) -> bool:
6868
return self.__wrapper(lambda: self.store.upsert(kind, item))
6969

7070
@property

‎ldclient/impl/datasystem/fdv2.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -83,10 +83,10 @@ def get(self, kind, key, callback):
8383
def all(self, kind, callback):
8484
return self.__wrapper(lambda: self.store.all(kind, callback))
8585

86-
def delete(self, kind, key, version):
86+
def delete(self, kind, key, version) -> bool:
8787
return self.__wrapper(lambda: self.store.delete(kind, key, version))
8888

89-
def upsert(self, kind, item):
89+
def upsert(self, kind, item) -> bool:
9090
return self.__wrapper(lambda: self.store.upsert(kind, item))
9191

9292
@property

‎ldclient/interfaces.py‎

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -111,26 +111,36 @@ def init(self, all_data: Mapping[VersionedDataKind, Mapping[str, dict]]):
111111
"""
112112

113113
@abstractmethod
114-
def delete(self, kind: VersionedDataKind, key: str, version: int):
114+
def delete(self, kind: VersionedDataKind, key: str, version: int) -> bool:
115115
"""
116116
Deletes the object associated with the specified key, if it exists and its version is less than
117117
the specified version. The object should be replaced in the data store by a
118-
placeholder with the specified version and a "deleted" property of TErue.
118+
placeholder with the specified version and a "deleted" property of True.
119+
120+
Implementations must return whether they applied the delete. The SDK uses this result to
121+
decide whether to send flag change events.
119122
120123
:param kind: The kind of object to delete
121124
:param key: The key of the object to be deleted
122125
:param version: The version for the delete operation
126+
:return: True if the store was updated (a newer delete placeholder was written); False if the
127+
delete was rejected because the stored version was equal or newer
123128
"""
124129

125130
@abstractmethod
126-
def upsert(self, kind: VersionedDataKind, item: dict):
131+
def upsert(self, kind: VersionedDataKind, item: dict) -> bool:
127132
"""
128133
Updates or inserts the object associated with the specified key. If an item with the same key
129134
already exists, it should update it only if the new item's version property is greater than
130135
the old one.
131136
137+
Implementations must return whether they applied the update. The SDK uses this result to
138+
decide whether to send flag change events.
139+
132140
:param kind: The kind of object to update
133141
:param item: The object to update or insert
142+
:return: True if the store was updated (the item was new or a newer version); False if the
143+
update was rejected because the stored version was equal or newer
134144
"""
135145

136146
@property
@@ -267,6 +277,10 @@ def upsert_internal(self, kind: VersionedDataKind, item: dict) -> dict:
267277
then it returns the item that is currently in the data store (this ensures that
268278
``CachingStoreWrapper`` will update the cache correctly).
269279
280+
``CachingStoreWrapper`` compares the returned object to the object that it passed in. If they
281+
are the same object, it reports the write as applied. Thus, on success, return the same
282+
object that was passed in, not a copy.
283+
270284
:param kind: The kind of object to update
271285
:param item: The object to update or insert
272286
:return: The state of the object after the update

‎ldclient/testing/feature_store_test_base.py‎

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -96,41 +96,41 @@ def test_get_all_versions(self, tester):
9696
def test_upsert_with_newer_version(self, tester):
9797
with self.inited_store(tester) as store:
9898
new_ver = self.make_feature('foo', 11)
99-
store.upsert(FEATURES, new_ver)
99+
assert store.upsert(FEATURES, new_ver) is True
100100
assert store.get(FEATURES, 'foo', lambda x: x) == new_ver
101101

102102
def test_upsert_with_older_version(self, tester):
103103
with self.inited_store(tester) as store:
104104
new_ver = self.make_feature('foo', 9)
105105
expected = self.make_feature('foo', 10)
106-
store.upsert(FEATURES, new_ver)
106+
assert store.upsert(FEATURES, new_ver) is False
107107
assert store.get(FEATURES, 'foo', lambda x: x) == expected
108108

109109
def test_upsert_with_new_feature(self, tester):
110110
with self.inited_store(tester) as store:
111111
new_ver = self.make_feature('biz', 1)
112-
store.upsert(FEATURES, new_ver)
112+
assert store.upsert(FEATURES, new_ver) is True
113113
assert store.get(FEATURES, 'biz', lambda x: x) == new_ver
114114

115115
def test_delete_with_newer_version(self, tester):
116116
with self.inited_store(tester) as store:
117-
store.delete(FEATURES, 'foo', 11)
117+
assert store.delete(FEATURES, 'foo', 11) is True
118118
assert store.get(FEATURES, 'foo', lambda x: x) is None
119119

120120
def test_delete_unknown_feature(self, tester):
121121
with self.inited_store(tester) as store:
122-
store.delete(FEATURES, 'biz', 11)
122+
assert store.delete(FEATURES, 'biz', 11) is True
123123
assert store.get(FEATURES, 'biz', lambda x: x) is None
124124

125125
def test_delete_with_older_version(self, tester):
126126
with self.inited_store(tester) as store:
127-
store.delete(FEATURES, 'foo', 9)
127+
assert store.delete(FEATURES, 'foo', 9) is False
128128
expected = self.make_feature('foo', 10)
129129
assert store.get(FEATURES, 'foo', lambda x: x) == expected
130130

131131
def test_upsert_older_version_after_delete(self, tester):
132132
with self.inited_store(tester) as store:
133133
store.delete(FEATURES, 'foo', 11)
134134
old_ver = self.make_feature('foo', 9)
135-
store.upsert(FEATURES, old_ver)
135+
assert store.upsert(FEATURES, old_ver) is False
136136
assert store.get(FEATURES, 'foo', lambda x: x) is None

‎ldclient/testing/impl/datasystem/test_cache_lifecycle.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -295,10 +295,10 @@ def all(self, kind, callback=lambda x: x):
295295
return callback({})
296296

297297
def delete(self, kind, key, version):
298-
pass
298+
return False
299299

300300
def upsert(self, kind, item):
301-
pass
301+
return False
302302

303303
@property
304304
def initialized(self):

‎ldclient/testing/impl/datasystem/test_fdv2_persistence.py‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -68,18 +68,22 @@ def all(
6868
items = self._data.get(kind, {})
6969
return {key: callback(value) for key, value in items.items()}
7070

71-
def delete(self, kind: VersionedDataKind, key: str, version: int):
71+
def delete(self, kind: VersionedDataKind, key: str, version: int) -> bool:
7272
self.delete_calls.append((kind, key, version))
7373
existing = self._data.get(kind, {}).get(key)
7474
if existing and existing.get("version", 0) < version:
7575
self._data[kind][key] = {"key": key, "version": version, "deleted": True}
76+
return True
77+
return False
7678

77-
def upsert(self, kind: VersionedDataKind, item: dict):
79+
def upsert(self, kind: VersionedDataKind, item: dict) -> bool:
7880
self.upsert_calls.append((kind, item.get("key"), item.get("version")))
7981
key = item["key"]
8082
existing = self._data.get(kind, {}).get(key)
8183
if not existing or existing.get("version", 0) < item.get("version", 0):
8284
self._data[kind][key] = item
85+
return True
86+
return False
8387

8488
@property
8589
def initialized(self) -> bool:

‎ldclient/testing/impl/test_data_sink.py‎

Lines changed: 19 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,19 @@ def test_upsert_triggers_flag_listener(basic_data):
194194
assert spy.statuses[0].key == 'flag1'
195195

196196

197+
def test_rejected_upsert_does_not_trigger_flag_listener(basic_data):
198+
flag_change_listener = Listeners()
199+
sink = DataSourceUpdateSinkImpl(InMemoryFeatureStore(), Listeners(), flag_change_listener)
200+
sink.init(basic_data)
201+
202+
spy = SpyListener()
203+
flag_change_listener.add(spy)
204+
# The store already has version 1 of this flag, so it rejects this write.
205+
sink.upsert(FEATURES, FlagBuilder('flag1').version(1).on(False).build())
206+
207+
assert len(spy.statuses) == 0
208+
209+
197210
def test_delete_triggers_flag_listener(basic_data):
198211
flag_change_listener = Listeners()
199212
sink = DataSourceUpdateSinkImpl(InMemoryFeatureStore(), Listeners(), flag_change_listener)
@@ -202,10 +215,8 @@ def test_delete_triggers_flag_listener(basic_data):
202215
spy = SpyListener()
203216
flag_change_listener.add(spy)
204217
sink.delete(FEATURES, 'flag1', 2)
205-
206-
# TODO(sc-212471): Once the store starts returning a success status on delete, the flag change
207-
# notification can start ignoring duplicate requests like this.
208-
# sink.delete(FEATURES, 'flag1', 2)
218+
# The store rejects this duplicate delete, so it sends no change event.
219+
sink.delete(FEATURES, 'flag1', 2)
209220

210221
assert len(spy.statuses) == 1
211222
assert spy.statuses[0].key == 'flag1'
@@ -304,9 +315,8 @@ def test_triggers_dependent_segment_is_modified(prereq_data):
304315
flag_change_listener.add(spy)
305316

306317
sink.upsert(SEGMENTS, SegmentBuilder('segment1').version(2).build())
307-
# TODO(sc-212471): Once the store starts returning a success status on upsert, the flag change
308-
# notification can start ignoring duplicate requests like this.
309-
# sink.upsert(SEGMENTS, SegmentBuilder('segment1').version(2).build())
318+
# The store rejects this duplicate upsert, so it sends no change events.
319+
sink.upsert(SEGMENTS, SegmentBuilder('segment1').version(2).build())
310320

311321
assert len(spy.statuses) == 3
312322

@@ -325,9 +335,8 @@ def test_triggers_if_dependent_segment_removed(prereq_data):
325335
flag_change_listener.add(spy)
326336

327337
sink.delete(SEGMENTS, 'segment2', 2)
328-
# TODO(sc-212471): Once the store starts returning a success status on delete, the flag change
329-
# notification can start ignoring duplicate requests like this.
330-
# sink.delete(SEGMENTS, 'segment2', 2)
338+
# The store rejects this duplicate delete, so it sends no change events.
339+
sink.delete(SEGMENTS, 'segment2', 2)
331340

332341
assert len(spy.statuses) == 3
333342

0 commit comments

Comments
 (0)