Skip to content

Commit 5f44e61

Browse files
authored
fix: Allow tombstones without a key property (#502)
1 parent 3d8600d commit 5f44e61

11 files changed

Lines changed: 131 additions & 6 deletions

ldclient/impl/integrations/consul/consul_feature_store.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,10 +86,16 @@ def get_internal(self, kind, key):
8686

8787
def get_all_internal(self, kind):
8888
items_out = {}
89+
# Use the key that each item is stored under, not the key inside the item. A deleted
90+
# item (a "tombstone") is not guaranteed to have a key of its own.
91+
item_key_prefix = self._kind_key(kind) + '/'
8992
index, results = self._client.kv.get(self._kind_key(kind), recurse=True)
9093
for result in results:
94+
db_key = result['Key']
95+
if not db_key.startswith(item_key_prefix):
96+
continue
9197
item = json.loads(result['Value'].decode('utf-8'))
92-
items_out[item['key']] = item
98+
items_out[db_key[len(item_key_prefix):]] = item
9399
return items_out
94100

95101
def upsert_internal(self, kind, new_item):

ldclient/impl/integrations/dynamodb/dynamodb_feature_store.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,9 @@ def get_all_internal(self, kind):
9999
for resp in paginator.paginate(**self._make_query_for_kind(kind)):
100100
for item in resp['Items']:
101101
item_out = self._unmarshal_item(item)
102-
items_out[item_out['key']] = item_out
102+
# Use the sort key that each item is stored under, not the key inside the item.
103+
# A deleted item (a "tombstone") is not guaranteed to have a key of its own.
104+
items_out[item[self.SORT_KEY]['S']] = item_out
103105
return items_out
104106

105107
def upsert_internal(self, kind, item):

ldclient/impl/model/feature_flag.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,11 +104,13 @@ def __init__(self, data: dict):
104104
# be absent even if they are really required in the schema. That's for backward compatibility
105105
# with test logic that constructed incomplete JSON, and also with the file data source which
106106
# previously allowed users to get away with leaving out a lot of properties in the JSON.
107-
self._key = req_str(data, 'key')
108107
self._version = req_int(data, 'version')
109108
self._deleted = opt_bool(data, 'deleted')
110109
if self._deleted:
110+
# Tombstones are not guaranteed to have a key.
111+
self._key = opt_str(data, 'key') or ''
111112
return
113+
self._key = req_str(data, 'key')
112114
self._variations = opt_list(data, 'variations')
113115
self._on = opt_bool(data, 'on')
114116
self._off_variation = opt_int(data, 'offVariation')

ldclient/impl/model/segment.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,11 +73,13 @@ def __init__(self, data: dict):
7373
# be absent even if they are really required in the schema. That's for backward compatibility
7474
# with test logic that constructed incomplete JSON, and also with the file data source which
7575
# previously allowed users to get away with leaving out a lot of properties in the JSON.
76-
self._key = req_str(data, 'key')
7776
self._version = req_int(data, 'version')
7877
self._deleted = opt_bool(data, 'deleted')
7978
if self._deleted:
79+
# Tombstones are not guaranteed to have a key.
80+
self._key = opt_str(data, 'key') or ''
8081
return
82+
self._key = req_str(data, 'key')
8183
self._included = set(opt_str_list(data, 'included'))
8284
self._excluded = set(opt_str_list(data, 'excluded'))
8385
self._included_contexts = list(SegmentTarget(item) for item in opt_dict_list(data, 'includedContexts'))

ldclient/testing/impl/test_model_decode.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
from ldclient.impl.model import *
77
from ldclient.testing.builders import *
8+
from ldclient.versioned_data_kind import FEATURES, SEGMENTS
89

910

1011
def test_flag_targets_are_stored_as_sets():
@@ -41,3 +42,38 @@ def test_clause_values_preprocessed_with_time_operator(op):
4142
flag = make_boolean_flag_with_clauses(make_clause(None, "attr", op, 1000, "1970-01-01T00:00:02Z", True))
4243
assert flag.rules[0].clauses[0]._values == [1000, "1970-01-01T00:00:02Z", True]
4344
assert list(x.as_time for x in flag.rules[0].clauses[0]._values_preprocessed) == [1000, 2000, None]
45+
46+
47+
@pytest.mark.parametrize('kind', [FEATURES, SEGMENTS])
48+
def test_tombstone_without_key_can_be_decoded(kind):
49+
# Other LaunchDarkly SDKs write deleted items to a persistent store with only the version,
50+
# so we must be able to read them back.
51+
item = kind.decode({"version": 5, "deleted": True})
52+
assert item.version == 5
53+
assert item.deleted is True
54+
assert item.key == ''
55+
# The original data must round-trip unchanged, because the store re-serializes it.
56+
assert item.to_json_dict() == {"version": 5, "deleted": True}
57+
58+
59+
@pytest.mark.parametrize('kind', [FEATURES, SEGMENTS])
60+
def test_tombstone_with_placeholder_key_can_be_decoded(kind):
61+
# The Go SDK and the Relay Proxy write deleted items with a placeholder key.
62+
item = kind.decode({"key": "$deleted", "version": 5, "deleted": True})
63+
assert item.version == 5
64+
assert item.deleted is True
65+
assert item.key == '$deleted'
66+
67+
68+
@pytest.mark.parametrize('kind', [FEATURES, SEGMENTS])
69+
def test_tombstone_still_requires_version(kind):
70+
with pytest.raises(ValueError):
71+
kind.decode({"deleted": True})
72+
73+
74+
@pytest.mark.parametrize('kind', [FEATURES, SEGMENTS])
75+
def test_item_that_is_not_deleted_still_requires_key(kind):
76+
with pytest.raises(ValueError):
77+
kind.decode({"version": 5})
78+
with pytest.raises(ValueError):
79+
kind.decode({"version": 5, "deleted": False})

ldclient/testing/integrations/persistent_feature_store_test_base.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,18 @@ def clear_data(self, prefix: str):
4040
"""
4141
pass
4242

43+
@abstractmethod
44+
def write_raw_item(self, prefix: str, kind, key: str, item: dict):
45+
"""
46+
Override this method to write an item straight to the database, with no help from the
47+
store. This lets a test set up data in a shape that the store itself does not write.
48+
:param prefix: the prefix parameter for the store constructor - may be None or empty to use the default
49+
:param kind: the kind of data, such as FEATURES
50+
:param key: the key to store the item under
51+
:param item: the item, to be stored as JSON
52+
"""
53+
pass
54+
4355
def create_feature_store(self) -> FeatureStore:
4456
return self.create_persistent_feature_store(self.prefix, self.caching)
4557

@@ -63,6 +75,16 @@ def tester(self, request):
6375
def clear_data_before_each(self, tester):
6476
tester.clear_data(tester.prefix)
6577

78+
def test_all_reads_tombstone_with_no_key(self, tester):
79+
# Other LaunchDarkly SDKs write a deleted item with only a version, and no key of its
80+
# own. The store must read these back with the key that the item is stored under.
81+
with self.inited_store(tester) as store:
82+
tester.write_raw_item(tester.prefix, FEATURES, 'deleted-flag', {'version': 5, 'deleted': True})
83+
84+
items = store.all(FEATURES, lambda x: x)
85+
assert items == {'foo': self.make_feature('foo', 10), 'bar': self.make_feature('bar', 10)}
86+
assert store.get(FEATURES, 'deleted-flag', lambda x: x) is None
87+
6688
def test_stores_with_different_prefixes_are_independent(self):
6789
# This verifies that init(), get(), all(), and upsert() are all correctly using the specified key prefix.
6890
# The delete() method isn't tested separately because it's implemented as a variant of upsert().

ldclient/testing/integrations/test_consul.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import json
2+
13
import pytest
24

35
from ldclient.integrations import Consul
@@ -39,6 +41,11 @@ def clear_data(self, prefix):
3941
for key in keys or []:
4042
client.kv.delete(key)
4143

44+
def write_raw_item(self, prefix, kind, key, item):
45+
client = consul.Consul()
46+
db_key = "%s/%s/%s" % (prefix or Consul.DEFAULT_PREFIX, kind.namespace, key)
47+
client.kv.put(db_key, json.dumps(item))
48+
4249

4350
class TestConsulFeatureStore(PersistentFeatureStoreTestBase):
4451
@property

ldclient/testing/integrations/test_dynamodb.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import json
12
import time
23

34
from ldclient.impl.integrations.dynamodb.dynamodb_big_segment_store import (
@@ -106,6 +107,19 @@ def create_persistent_feature_store(self, prefix, caching) -> FeatureStore:
106107
def clear_data(self, prefix):
107108
DynamoDBTestHelper.clear_data_for_prefix(prefix)
108109

110+
def write_raw_item(self, prefix, kind, key, item):
111+
client = DynamoDBTestHelper.make_client()
112+
namespace = (prefix + ":" if prefix else "") + kind.namespace
113+
client.put_item(
114+
TableName=DynamoDBTestHelper.table_name,
115+
Item={
116+
_DynamoDBFeatureStoreCore.PARTITION_KEY: {'S': namespace},
117+
_DynamoDBFeatureStoreCore.SORT_KEY: {'S': key},
118+
_DynamoDBFeatureStoreCore.VERSION_ATTRIBUTE: {'N': str(item['version'])},
119+
_DynamoDBFeatureStoreCore.ITEM_JSON_ATTRIBUTE: {'S': json.dumps(item)},
120+
},
121+
)
122+
109123

110124
class DynamoDBBigSegmentTester(BigSegmentStoreTester):
111125
def __init__(self):

ldclient/testing/integrations/test_redis.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,11 @@ def create_persistent_feature_store(self, prefix, caching) -> FeatureStore:
5454
def clear_data(self, prefix):
5555
RedisTestHelper.clear_data_for_prefix(prefix or Redis.DEFAULT_PREFIX)
5656

57+
def write_raw_item(self, prefix, kind, key, item):
58+
r = RedisTestHelper.make_client()
59+
items_key = "%s:%s" % (prefix or Redis.DEFAULT_PREFIX, kind.namespace)
60+
r.hset(items_key, key, json.dumps(item))
61+
5762

5863
class RedisBigSegmentStoreTester(BigSegmentStoreTester):
5964
def create_big_segment_store(self, prefix) -> BigSegmentStore:

ldclient/testing/test_async_feature_store_helpers.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
from ldclient.async_feature_store_helpers import AsyncCachingStoreWrapper
66
from ldclient.feature_store import CacheConfig
7-
from ldclient.versioned_data_kind import VersionedDataKind
7+
from ldclient.versioned_data_kind import FEATURES, SEGMENTS, VersionedDataKind
88

99
# These tests exercise the caching-wrapper logic only, using an in-memory mock core, so they run
1010
# without a Redis instance. They mirror ldclient.testing.test_feature_store_helpers for the sync
@@ -205,6 +205,21 @@ async def test_get_all_removes_deleted_items(self, cached):
205205
core.force_set(THINGS, item2)
206206
assert await wrapper.all(THINGS) == {item1["key"]: item1}
207207

208+
@pytest.mark.asyncio
209+
@pytest.mark.parametrize("kind", [FEATURES, SEGMENTS])
210+
@pytest.mark.parametrize("cached", [False, True])
211+
async def test_get_all_tolerates_tombstone_with_no_key(self, cached, kind):
212+
# Other LaunchDarkly SDKs write deleted items to a persistent store with only the
213+
# version. The store knows the key, because it is the key the item is stored under.
214+
core = MockAsyncCore()
215+
wrapper = make_wrapper(core, cached)
216+
live_item = {"key": "item1", "version": 1}
217+
tombstone = {"version": 2, "deleted": True}
218+
core.data[kind] = {"item1": live_item, "item2": tombstone}
219+
220+
assert await wrapper.all(kind) == {"item1": kind.decode(live_item)}
221+
assert await wrapper.get(kind, "item2") is None
222+
208223
@pytest.mark.asyncio
209224
@pytest.mark.parametrize("cached", [False, True])
210225
async def test_get_all_changes_None_to_empty_dict(self, cached):

0 commit comments

Comments
 (0)