Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 38 additions & 6 deletions ldclient/impl/integrations/files/filedata.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,11 @@
keep their own implementation and behavior.

A data file is a JSON or YAML document with optional ``flags``, ``flagValues``, and
``segments`` members. ``flags`` and ``segments`` hold full definitions keyed by key.
``flagValues`` maps a flag key to a single value. It expands into a full flag definition
that returns that value for every context. The file name extension decides the format: a
file whose name ends in ``.yaml`` or ``.yml`` is YAML, and any other file is JSON.
``segments`` members. ``flags`` and ``segments`` hold full definitions keyed by key, and a
definition marked as deleted fails the load of its file. ``flagValues`` maps a flag key to a
single value. It expands into a full flag definition that returns that value for every
context. The file name extension decides the format: a file whose name ends in ``.yaml`` or
``.yml`` is YAML, and any other file is JSON.
"""

import codecs
Expand Down Expand Up @@ -92,6 +93,19 @@ def __init__(self, path: str, message: str):
self.path = path


class DeletedEntryError(FileReadError):
"""
Indicates that a file holds a flag or segment entry marked as deleted. An override is removed
by removing its entry, so such a file fails to load like one that cannot be parsed.
"""

def __init__(self, path: str, kind_name: str, key: str):
FileDataError.__init__(self, '%s "%s" in %s is marked as deleted; remove the entry to remove its override' % (kind_name, key, path))
self.path = path
self.kind_name = kind_name
self.key = key


class DuplicateKeyError(FileDataError):
"""Indicates that the same key appears in more than one file and the handling is FAIL."""

Expand Down Expand Up @@ -170,9 +184,11 @@ def read_file(path: str) -> Document:
except OSError as e:
raise FileReadError(path, "unable to read file: %s" % e)
try:
return parse_document(raw, is_yaml_path(path))
document = parse_document(raw, is_yaml_path(path))
except Exception as e:
raise FileReadError(path, "error parsing file: %s" % e)
_reject_deleted_entries(path, document)
return document


def has_content(raw: bytes) -> bool:
Expand Down Expand Up @@ -336,9 +352,25 @@ def _parse_file(path: str, raw: bytes) -> Optional[Document]:
"""Parses what was read from a file that exists. Returns None when the parser read nothing."""
is_yaml = is_yaml_path(path)
try:
return _parse_content(raw, is_yaml)
document = _parse_content(raw, is_yaml)
except Exception as e:
raise FileReadError(path, "error parsing file as %s, the format of its name: %s" % ("YAML" if is_yaml else "JSON", e))
if document is not None:
_reject_deleted_entries(path, document)
return document


def _reject_deleted_entries(path: str, document: Document) -> None:
"""
Raises :class:`DeletedEntryError` for the first flag or segment entry of a parsed file that is
marked as deleted. The override layer never holds a deleted item, and an override is removed
by removing its entry, so such an entry is a mistake in the file that fails its load. The file
data sources do not use this module and keep accepting such entries.
"""
for kind_name, items in (("flag", document.flags), ("segment", document.segments)):
for key, item in items.items():
if item.deleted:
raise DeletedEntryError(path, kind_name, key)


def _parse_files(contents: List[_FileRead], duplicate_keys_handling: DuplicateKeysHandling) -> Tuple[MergeResult, bytes]:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,10 +47,10 @@ class _FileOverrideSource(OverrideSource):
"""
Reads overrides from the configured files and supplies each successful load to the sink as
a full snapshot. A configured file that does not exist contributes no overrides. A file
that cannot be read or parsed fails that load, the last good snapshot stays in effect, and
the load is retried. A file whose content is in effect and that is found with no entries is
held back until it has stayed so for the retry delay, because it may be in the middle of an
in-place rewrite.
that cannot be read or parsed, or that holds an entry marked as deleted, fails that load,
the last good snapshot stays in effect, and the load is retried. A file whose content is in
effect and that is found with no entries is held back until it has stayed so for the retry
delay, because it may be in the middle of an in-place rewrite.
"""

def __init__(self, paths: List[str], duplicate_keys_handling: DuplicateKeysHandling, change_detection: ChangeDetection, poll_interval: float):
Expand Down
20 changes: 18 additions & 2 deletions ldclient/impl/overrides/layer.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,19 @@ def _empty_contents() -> LayerContents:
return {FEATURES: {}, SEGMENTS: {}}


class DeletedItemError(ValueError):
"""
Indicates that a snapshot holds a flag or segment marked as deleted. The layer never holds a
deleted item, so it rejects the whole snapshot. An override is removed by leaving it out of
the snapshot.
"""

def __init__(self, kind_name: str, key: str):
super().__init__('%s "%s" is marked as deleted; an override is removed by leaving it out of the snapshot' % (kind_name, key))
self.kind_name = kind_name
self.key = key


class OverrideLayer:
"""
A thread-safe store of override entries, replaced wholesale on each update from an override
Expand All @@ -41,8 +54,9 @@ def set_all(self, flags: Mapping[str, Any], segments: Mapping[str, Any]) -> Tupl

Values may be model objects or their JSON dictionary form. A dictionary is decoded with
the model constructor, which raises ``ValueError`` for an invalid definition. A
definition whose key differs from its key in the mapping raises ``ValueError`` too.
Nothing is replaced in either case.
definition whose key differs from its key in the mapping raises ``ValueError`` too, and a
flag or segment marked as deleted, in either form, raises :class:`DeletedItemError`,
which is a ``ValueError``. Nothing is replaced in any of these cases.

:return: the previous and the new contents. The returned dictionaries must not be modified.
"""
Expand All @@ -52,6 +66,8 @@ def set_all(self, flags: Mapping[str, Any], segments: Mapping[str, Any]) -> Tupl
}
for kind, items in decoded.items():
for key, item in items.items():
if item.deleted:
raise DeletedItemError("flag" if kind == FEATURES else "segment", key)
# Evaluation reads an entry by its mapping key, while the all-flags report and
# analytics events use the definition's key. A mismatch would serve one key's
# override under another, so the whole snapshot is rejected.
Expand Down
14 changes: 12 additions & 2 deletions ldclient/impl/overrides/sink.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,11 @@
from typing import Any, Callable, Dict, Optional, Set

from ldclient.impl.dependency_tracker import DependencyTracker, KindAndKey
from ldclient.impl.overrides.layer import LayerContents, OverrideLayer
from ldclient.impl.overrides.layer import (
DeletedItemError,
LayerContents,
OverrideLayer
)
from ldclient.impl.util import log
from ldclient.interfaces import OverrideSink, ReadOnlyStore
from ldclient.versioned_data_kind import FEATURES, SEGMENTS, VersionedDataKind
Expand Down Expand Up @@ -51,7 +55,13 @@ def set_overrides(self, flags: Dict[str, Any], segments: Dict[str, Any]) -> None
if self._closed:
log.debug("Ignoring an override snapshot supplied after the override source was closed")
return
self._apply(flags, segments)
try:
self._apply(flags, segments)
except DeletedItemError as e:
# The layer rejected the whole snapshot before changing anything, so nothing is
# notified. The error also reaches the source, as for an invalid definition.
log.error("Rejected an override snapshot; the overrides in effect stay: %s", e)
raise

def close(self) -> None:
"""
Expand Down
3 changes: 2 additions & 1 deletion ldclient/integrations/overrides.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,8 @@ class FileOverrideSourceBuilder(OverrideSourceBuilder):
overrides, and deleting every file removes them all. A file that exists but cannot be read
or parsed makes that whole reload fail. The previously loaded overrides stay in effect, the
source logs the failure, retries after a short delay, and recovers on its own once the file
is readable again.
is readable again. A file that contains an entry marked as deleted fails to load, and the
overrides in effect stay; to remove an override, remove its entry or delete the file.

Files can be rewritten in place: if the source finds a file empty while it is being
rewritten, it keeps the overrides in effect until the new content is written. To remove a
Expand Down
6 changes: 4 additions & 2 deletions ldclient/interfaces.py
Original file line number Diff line number Diff line change
Expand Up @@ -2149,8 +2149,10 @@ def set_overrides(self, flags: Mapping[str, Any], segments: Mapping[str, Any]) -
either a :class:`ldclient.impl.model.FeatureFlag` or :class:`ldclient.impl.model.Segment`
model object or its JSON dictionary form, whose own key is the key it has in the mapping.
A dictionary that is not a valid definition, or a definition whose key differs from its
key in the mapping, raises ``ValueError``, and the layer is left unchanged. The SDK itself
marks the entries as overrides.
key in the mapping, raises ``ValueError``, and the layer is left unchanged. A snapshot
that contains a flag or segment marked as deleted is rejected with ``ValueError``, and
the overrides in effect stay; to remove an override, leave it out of the snapshot. The
SDK itself marks the entries as overrides.

This method is safe to call from any thread. Calls are serialized by the SDK.

Expand Down
76 changes: 76 additions & 0 deletions ldclient/testing/impl/test_overrides.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
Tests for the override layer, the overlay at the store read boundary, and the sink that applies
snapshots and notifies flag change listeners.
"""
import logging
from typing import Any, Dict, List, Optional

import pytest
Expand Down Expand Up @@ -131,6 +132,49 @@ def test_layer_rejects_a_definition_whose_key_differs_from_its_mapping_key(flags
assert layer.all(SEGMENTS) == {}


def snapshot_with_deleted_item(kind, as_model: bool):
"""
A snapshot that changes the entries flag1 and segment1 to version 2 and adds an item of the
given kind, named deleted-key, that is marked as deleted, as a model object or in its JSON
dictionary form. Because the snapshot changes the other entries too, applying any part of it
would show.
"""
data = {'key': 'deleted-key', 'version': 2, 'deleted': True}
deleted = {'deleted-key': kind.decode(data) if as_model else data}
flags = {'flag1': flag('flag1', 2), **(deleted if kind == FEATURES else {})}
segments = {'segment1': segment('segment1', 2), **(deleted if kind == SEGMENTS else {})}
return flags, segments


def deleted_item_message(kind) -> str:
return '%s "deleted-key" is marked as deleted; an override is removed by leaving it out of the snapshot' % ('flag' if kind == FEATURES else 'segment')


@pytest.mark.parametrize('as_model', [False, True], ids=['dict', 'model'])
@pytest.mark.parametrize('kind', [FEATURES, SEGMENTS], ids=['flag', 'segment'])
def test_layer_rejects_a_snapshot_that_holds_a_deleted_item_and_keeps_its_contents(kind, as_model):
layer = OverrideLayer()
layer.set_all({'flag1': flag('flag1')}, {'segment1': segment('segment1')})
flags, segments = snapshot_with_deleted_item(kind, as_model)

with pytest.raises(ValueError) as e:
layer.set_all(flags, segments)

assert str(e.value) == deleted_item_message(kind)
assert layer.get(FEATURES, 'flag1').version == 1
assert layer.get(SEGMENTS, 'segment1').version == 1
assert layer.get(kind, 'deleted-key') is None


def test_layer_accepts_definitions_marked_as_not_deleted():
layer = OverrideLayer()
layer.set_all({'flag1': dict(flag('flag1').to_json_dict(), deleted=False)}, {'segment1': dict(segment('segment1').to_json_dict(), deleted=False)})
stored_flag = layer.get(FEATURES, 'flag1')
assert stored_flag is not None
assert stored_flag.on is True
assert layer.get(SEGMENTS, 'segment1') is not None


def test_layer_replacement_semantics():
layer = OverrideLayer()
assert layer.is_empty is True
Expand Down Expand Up @@ -475,3 +519,35 @@ def test_sink_invalid_snapshot_raises_and_changes_nothing():
f.sink.set_overrides({'flag1': {'key': 'flag1', 'version': 'bad'}}, {})
assert f.layer.get(FEATURES, 'flag1').version == 1
assert f.take_notified() == []


@pytest.mark.parametrize('listening', [True, False], ids=['listening', 'not-listening'])
@pytest.mark.parametrize('as_model', [False, True], ids=['dict', 'model'])
@pytest.mark.parametrize('kind', [FEATURES, SEGMENTS], ids=['flag', 'segment'])
def test_sink_rejects_a_snapshot_that_holds_a_deleted_item_logs_it_and_notifies_nothing(kind, as_model, listening, caplog):
dependent = FlagBuilder('dependent').version(1).on(True).variations(True, False).fallthrough_variation(1).rules(
FlagRuleBuilder().variation(0).clauses(make_clause_matching_segment_key('segment1')).build()
).build()
f = SinkFixture(FakeBaseStore(flags={'dependent': dependent}))
f.sink.set_overrides({'flag1': flag('flag1')}, {'segment1': segment('segment1')})
f.take_notified()
f.listening = listening
caplog.clear()
flags, segments = snapshot_with_deleted_item(kind, as_model)

with pytest.raises(ValueError) as e:
f.sink.set_overrides(flags, segments)

assert str(e.value) == deleted_item_message(kind)
assert f.layer.get(FEATURES, 'flag1').version == 1
assert f.layer.get(SEGMENTS, 'segment1').version == 1
assert f.layer.get(kind, 'deleted-key') is None
assert f.take_notified() == []
errors = [r.getMessage() for r in caplog.records if r.levelno == logging.ERROR]
assert errors == ['Rejected an override snapshot; the overrides in effect stay: ' + deleted_item_message(kind)]

# The sink still accepts the next snapshot.
f.listening = True
f.sink.set_overrides({'flag1': flag('flag1', 3)}, {})
assert f.layer.get(FEATURES, 'flag1').version == 3
assert f.take_notified() == ['dependent', 'flag1']
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@
initializer and synchronizer. The file-based override source is built on separate code and
behaves differently in several of these respects. These tests keep the existing sources
observably unchanged: the flagValues expansion and its evaluation reason, the version fallback,
the duplicate key and load failure messages, the FDv2 status and error kinds, and the polling
and watching rules.
the duplicate key and load failure messages, the FDv2 status and error kinds, the acceptance of
entries marked as deleted, and the polling and watching rules.
"""
import logging
import os
Expand Down Expand Up @@ -291,6 +291,55 @@ def collect():
thread.join(5)


# ---------------------------------------------------------------------------
# Entries marked as deleted. The override source rejects a file that holds one. These sources
# accept it, and rejecting it here is a breaking change left to a later major version.
# ---------------------------------------------------------------------------

DOCUMENT_WITH_DELETED_ENTRIES = '''
{
"flags": {
"deleted-flag": {"key": "deleted-flag", "version": 3, "deleted": true},
"flag1": {"key": "flag1", "on": false, "offVariation": 0, "variations": ["x"]}
},
"segments": {
"deleted-segment": {"key": "deleted-segment", "deleted": true}
}
}
'''


def test_v1_loads_a_file_with_deleted_entries_and_stores_them_as_deleted(tmp_path, caplog):
path = os.path.join(str(tmp_path), 'data.json')
write_file(path, DOCUMENT_WITH_DELETED_ENTRIES)
store = InMemoryFeatureStore()
source = make_v1_source(path, store)
with caplog.at_level(logging.ERROR):
source.start()
try:
assert store.initialized is True
assert [r.getMessage() for r in caplog.records if r.levelno == logging.ERROR] == []
assert store.get(FEATURES, 'flag1') is not None
assert store.get(FEATURES, 'deleted-flag') is None
assert store.get(SEGMENTS, 'deleted-segment') is None
assert list(store.all(FEATURES, lambda x: x).keys()) == ['flag1']
finally:
source.stop()


def test_v2_supplies_deleted_entries_as_they_are_in_the_file(tmp_path):
path = os.path.join(str(tmp_path), 'data.json')
write_file(path, DOCUMENT_WITH_DELETED_ENTRIES)
result = make_v2_source(path).fetch(MockSelectorStore(Selector.no_selector()))
assert result.value is not None
changes = v2_changes_by_key(result.value.change_set)
assert set(changes.keys()) == {'deleted-flag', 'flag1', 'deleted-segment'}
assert changes['deleted-flag'].object == {'key': 'deleted-flag', 'version': 3, 'deleted': True}
assert changes['deleted-flag'].version == 3
assert changes['deleted-segment'].kind == ObjectKind.SEGMENT
assert changes['deleted-segment'].object == {'key': 'deleted-segment', 'version': 1, 'deleted': True}


# ---------------------------------------------------------------------------
# Polling rules: modification time only, a missing file is not a change
# ---------------------------------------------------------------------------
Expand Down
Loading
Loading