Skip to content

Commit 030c519

Browse files
committed
fix: Allow tombstones without a key property
FeatureFlag and Segment validated the required "key" property before the deleted-item early-out, so a persisted tombstone written with only a version could not be decoded. Other LaunchDarkly SDKs (Node, .NET, Java) write keyless tombstones to a persistent store, so a shared store broke Python's all-flags read. The key requirement now applies only to items that are not deleted. A tombstone still requires a version, and .key falls back to an empty string so callers never fail. The stored data still round-trips verbatim.
1 parent 5da1515 commit 030c519

5 files changed

Lines changed: 77 additions & 4 deletions

File tree

ldclient/impl/model/feature_flag.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,11 +104,15 @@ 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+
# A deleted item (a "tombstone") does not need a key. Other LaunchDarkly SDKs write
111+
# tombstones to a persistent store with only the version, because the store already
112+
# knows the key. Use an empty string so that reading .key never fails.
113+
self._key = opt_str(data, 'key') or ''
111114
return
115+
self._key = req_str(data, 'key')
112116
self._variations = opt_list(data, 'variations')
113117
self._on = opt_bool(data, 'on')
114118
self._off_variation = opt_int(data, 'offVariation')

ldclient/impl/model/segment.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,11 +73,15 @@ 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+
# A deleted item (a "tombstone") does not need a key. Other LaunchDarkly SDKs write
80+
# tombstones to a persistent store with only the version, because the store already
81+
# knows the key. Use an empty string so that reading .key never fails.
82+
self._key = opt_str(data, 'key') or ''
8083
return
84+
self._key = req_str(data, 'key')
8185
self._included = set(opt_str_list(data, 'included'))
8286
self._excluded = set(opt_str_list(data, 'excluded'))
8387
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/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):

ldclient/testing/test_feature_store_helpers.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55

66
from ldclient.feature_store import CacheConfig
77
from ldclient.feature_store_helpers import CachingStoreWrapper
8-
from ldclient.versioned_data_kind import VersionedDataKind
8+
from ldclient.versioned_data_kind import FEATURES, SEGMENTS, VersionedDataKind
99

1010
THINGS = VersionedDataKind(namespace="things", request_api_path="", stream_api_path="")
1111
WRONG_THINGS = VersionedDataKind(namespace="wrong", request_api_path="", stream_api_path="")
@@ -189,6 +189,20 @@ def test_get_all_removes_deleted_items(self, cached):
189189
core.force_set(THINGS, item2)
190190
assert wrapper.all(THINGS) == {item1["key"]: item1}
191191

192+
@pytest.mark.parametrize("kind", [FEATURES, SEGMENTS])
193+
@pytest.mark.parametrize("cached", [False, True])
194+
def test_get_all_tolerates_tombstone_with_no_key(self, cached, kind):
195+
# Other LaunchDarkly SDKs write deleted items to a persistent store with only the
196+
# version. The store knows the key, because it is the key the item is stored under.
197+
core = MockCore()
198+
wrapper = make_wrapper(core, cached)
199+
live_item = {"key": "item1", "version": 1}
200+
tombstone = {"version": 2, "deleted": True}
201+
core.data[kind] = {"item1": live_item, "item2": tombstone}
202+
203+
assert wrapper.all(kind) == {"item1": kind.decode(live_item)}
204+
assert wrapper.get(kind, "item2") is None
205+
192206
@pytest.mark.parametrize("cached", [False, True])
193207
def test_get_all_changes_None_to_empty_dict(self, cached):
194208
core = MockCore()

0 commit comments

Comments
 (0)