Skip to content

Commit 2074b74

Browse files
committed
feat: Add the file-based override source
Adds ldclient.integrations.overrides.FileOverrideSourceBuilder, the file-based override source described by the OVERRIDE specification. The source reads one or more JSON or YAML files in the file data source document format, with optional flags, flagValues, and segments members, and supplies each successful load to the SDK's override sink as a full snapshot. Files are combined in the configured order. The duplicate keys handling is fail by default, which rejects the reload and keeps the previously loaded overrides, or ignore, which keeps the first file's entry. A configured file that does not exist contributes no overrides, so a file can be created later and deleting a file removes its overrides. A file that exists but cannot be read or parsed fails that reload, the last good overrides stay in effect, the failure is logged, and the load is retried after a bounded delay and on the next detected change. Change detection is one of two modes: polling, the default, examines the files once per second by default with a one second minimum, and watching reacts to file system notifications through the watchdog package. Watching without the watchdog package and a builder with no paths are construction errors. The initial load completes during client construction. Every applied change is logged at Info level with the overrides in effect and what each configured file supplied.
1 parent 0e55650 commit 2074b74

5 files changed

Lines changed: 751 additions & 0 deletions

File tree

‎docs/api-integrations.rst‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,3 +8,13 @@ ldclient.integrations module
88
:members:
99
:special-members: __init__
1010
:show-inheritance:
11+
12+
ldclient.integrations.overrides module
13+
--------------------------------------
14+
15+
The entry point for this feature is :class:`ldclient.integrations.overrides.FileOverrideSourceBuilder`.
16+
17+
.. automodule:: ldclient.integrations.overrides
18+
:members:
19+
:special-members: __init__
20+
:show-inheritance:

‎ldclient/impl/integrations/overrides/__init__.py‎

Whitespace-only changes.
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
"""
2+
The file-based override source. It reads flag and segment overrides from one or more local
3+
files and reloads them when the files change.
4+
"""
5+
6+
import threading
7+
from enum import Enum
8+
from typing import List, Optional, Union
9+
10+
from ldclient.impl.integrations.files.filedata import (
11+
DEFAULT_DEBOUNCE_DELAY,
12+
DEFAULT_RETRY_DELAY,
13+
DuplicateKeysHandling,
14+
FileSummary,
15+
MergeResult,
16+
Poller,
17+
Reloader,
18+
Watcher
19+
)
20+
from ldclient.impl.util import log
21+
from ldclient.interfaces import OverrideSink, OverrideSource
22+
23+
24+
class ChangeDetection(str, Enum):
25+
"""
26+
Selects how the file-based override source learns that a file changed. The two modes are
27+
alternatives. Flag overrides are currently experimental and subject to change.
28+
"""
29+
30+
POLLING = "polling"
31+
"""
32+
The source examines the files on a fixed interval and reloads when the modification time
33+
or the size of a file changes. Polling works on every file system, including network
34+
mounts and directories whose contents are swapped through symbolic links, as Kubernetes
35+
does for mounted ConfigMaps. It is the default.
36+
"""
37+
38+
WATCHING = "watching"
39+
"""
40+
The source reloads in response to file system change notifications, using the ``watchdog``
41+
package. It reacts faster than polling. It depends on notifications, which some file
42+
systems do not deliver reliably.
43+
"""
44+
45+
46+
class _FileOverrideSource(OverrideSource):
47+
"""
48+
Reads overrides from the configured files and supplies each successful load to the sink as
49+
a full snapshot. A configured file that does not exist contributes no overrides. A file
50+
that cannot be read or parsed fails that load, the last good snapshot stays in effect, and
51+
the load is retried.
52+
"""
53+
54+
def __init__(self, paths: List[str], duplicate_keys_handling: DuplicateKeysHandling, change_detection: ChangeDetection, poll_interval: float):
55+
self._paths = paths
56+
self._duplicate_keys_handling = duplicate_keys_handling
57+
self._change_detection = change_detection
58+
self._poll_interval = poll_interval
59+
self._reloader: Optional[Reloader] = None
60+
self._change_detector: Optional[Union[Poller, Watcher]] = None
61+
self._lock = threading.Lock()
62+
self._closed = False
63+
64+
def start(self, sink: OverrideSink) -> None:
65+
def apply(merged: MergeResult) -> None:
66+
sink.set_overrides(merged.flags, merged.segments)
67+
_log_overrides_in_effect(merged)
68+
69+
reloader = Reloader(
70+
self._paths,
71+
self._duplicate_keys_handling,
72+
apply=apply,
73+
skip_missing_paths=True,
74+
debounce_delay=DEFAULT_DEBOUNCE_DELAY,
75+
retry_delay=DEFAULT_RETRY_DELAY,
76+
skip_unchanged=True,
77+
)
78+
with self._lock:
79+
if self._closed:
80+
return
81+
self._reloader = reloader
82+
83+
# The initial load runs synchronously, so overrides present in the files are in effect
84+
# by the time the client constructor returns. A file that does not exist yet
85+
# contributes no overrides. A file that cannot be read or parsed is not fatal: the
86+
# client runs with no overrides, the failure is logged, and the retry recovers once
87+
# the file is readable.
88+
reloader.reload_now()
89+
90+
with self._lock:
91+
if self._closed:
92+
return
93+
if self._change_detection == ChangeDetection.WATCHING:
94+
self._change_detector = Watcher(self._paths, reloader.trigger)
95+
else:
96+
poller = Poller(self._paths, self._poll_interval, reloader.trigger)
97+
poller.start()
98+
self._change_detector = poller
99+
100+
def close(self) -> None:
101+
with self._lock:
102+
if self._closed:
103+
return
104+
self._closed = True
105+
change_detector = self._change_detector
106+
reloader = self._reloader
107+
self._change_detector = None
108+
self._reloader = None
109+
if change_detector is not None:
110+
change_detector.close()
111+
if reloader is not None:
112+
reloader.close()
113+
114+
115+
def _log_overrides_in_effect(merged: MergeResult) -> None:
116+
"""
117+
Reports the overrides now in effect and what each configured file supplied. The reloader
118+
applies a snapshot only when the content changed, so this logs each change once.
119+
"""
120+
details = "; ".join(_file_summary_text(summary) for summary in merged.files)
121+
if len(merged.flags) == 0 and len(merged.segments) == 0:
122+
log.info("Flag overrides: none in effect (%s)", details)
123+
return
124+
log.info("Flag overrides in effect: %s (%s)", _counts_text(len(merged.flags), len(merged.segments)), details)
125+
126+
127+
def _file_summary_text(summary: FileSummary) -> str:
128+
if not summary.present:
129+
return "%s: absent" % summary.path
130+
if summary.flags == 0 and summary.segments == 0:
131+
return "%s: no entries" % summary.path
132+
return "%s: %s" % (summary.path, _counts_text(summary.flags, summary.segments))
133+
134+
135+
def _counts_text(flags: int, segments: int) -> str:
136+
"""Formats flag and segment counts, for example "2 flags, 1 segment"."""
137+
parts = []
138+
if flags > 0:
139+
parts.append(_pluralize(flags, "flag"))
140+
if segments > 0:
141+
parts.append(_pluralize(segments, "segment"))
142+
return ", ".join(parts)
143+
144+
145+
def _pluralize(count: int, noun: str) -> str:
146+
if count == 1:
147+
return "1 %s" % noun
148+
return "%d %ss" % (count, noun)

‎ldclient/integrations/overrides.py‎

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
"""
2+
Override sources for the SDK's flag override capability. Flag overrides are currently
3+
experimental and subject to change.
4+
5+
Overrides are flag and segment definitions that take precedence over data received from
6+
LaunchDarkly at evaluation time, on a per-key basis. They exist for resilience during an
7+
incident. An operator can force one or more flags to a known state on a running application,
8+
whether or not the application can reach LaunchDarkly. The override stays in effect until the
9+
operator removes it. Flags not present in the override data are completely unaffected.
10+
11+
This module currently provides one source: :class:`FileOverrideSourceBuilder`, which reads
12+
overrides from local files and reloads them as the files change. Configure it with the data
13+
system builder:
14+
::
15+
16+
from ldclient import Config, datasystem
17+
from ldclient.integrations.overrides import FileOverrideSourceBuilder
18+
19+
source = FileOverrideSourceBuilder(['/etc/launchdarkly/overrides.json'])
20+
config = Config(sdk_key, datasystem_config=datasystem.default().overrides(source).build())
21+
22+
An evaluation that an override affects is marked. The marking is direct or transitive. It
23+
applies when the evaluated flag, a prerequisite at any depth, or a segment read during the
24+
evaluation came from the override layer. The evaluation reason's ``overrideAffected`` property
25+
reports the marking. Marked evaluations appear in analytics summary events only, under separate
26+
counters, so LaunchDarkly can distinguish them. They produce no individual evaluation events.
27+
"""
28+
29+
from typing import List, Optional, Union
30+
31+
from ldclient.config import DataSourceBuilderConfig, OverrideSourceBuilder
32+
from ldclient.impl.integrations.files.filedata import (
33+
DuplicateKeysHandling,
34+
abs_file_paths,
35+
have_watchdog
36+
)
37+
from ldclient.impl.integrations.overrides.file_override_source import (
38+
ChangeDetection,
39+
_FileOverrideSource
40+
)
41+
from ldclient.impl.util import log
42+
from ldclient.interfaces import OverrideSource
43+
44+
45+
class FileOverrideSourceBuilder(OverrideSourceBuilder):
46+
"""
47+
A builder for a file-based override source. Flag overrides are currently experimental and
48+
subject to change.
49+
50+
The source reads flag and segment overrides from one or more local files and reloads them
51+
as the files change. The files use the same document format as the file data source:
52+
each file is a JSON or YAML document with optional ``flags``, ``flagValues``, and
53+
``segments`` members. ``flagValues`` entries are expanded into full flag definitions that
54+
return the given value for every context. YAML requires the ``pyyaml`` package.
55+
56+
When multiple files are configured, their entries are combined in the configured order.
57+
The order determines which file wins under the duplicate keys handling. A reload replaces
58+
the entire override set, so removing an entry from the files removes the override. A
59+
configured file that does not exist contributes no overrides: deleting a file removes its
60+
overrides, and deleting every file removes them all. A file that exists but cannot be read
61+
or parsed makes that whole reload fail. The previously loaded overrides stay in effect, the
62+
source logs the failure, retries after a short delay, and recovers on its own once the file
63+
is readable again.
64+
65+
Whenever the set of overrides in effect changes, including at startup, the source logs the
66+
overrides in effect and what each configured file supplied, at Info level.
67+
68+
By default the source polls the files for changes once per second. See
69+
:meth:`change_detection` and :meth:`poll_interval`.
70+
"""
71+
72+
DEFAULT_POLL_INTERVAL = 1.0
73+
"""
74+
The interval, in seconds, at which the source examines the files for changes in polling
75+
mode when no interval was specified. Because the source reads local files rather than
76+
contacting a service, a short interval keeps an override responsive during an incident at
77+
negligible cost.
78+
"""
79+
80+
MINIMUM_POLL_INTERVAL = 1.0
81+
"""
82+
The shortest allowed polling interval, in seconds. A configured interval below this is
83+
raised to it. The minimum exists only to prevent a pathological tight loop over the file
84+
system.
85+
"""
86+
87+
def __init__(self, paths: Union[str, List[str]]):
88+
"""
89+
:param paths: the files to load overrides from, as a single path or a list of paths.
90+
The order is significant: it determines which file wins under the duplicate keys
91+
handling when the same key appears in more than one file. Relative paths are
92+
resolved against the current working directory when the source is built.
93+
"""
94+
self.__paths: List[str] = [paths] if isinstance(paths, str) else list(paths)
95+
self.__duplicate_keys_handling = DuplicateKeysHandling.FAIL
96+
self.__change_detection = ChangeDetection.POLLING
97+
self.__poll_interval = self.DEFAULT_POLL_INTERVAL
98+
99+
def duplicate_keys_handling(self, handling: Union[DuplicateKeysHandling, str]) -> 'FileOverrideSourceBuilder':
100+
"""
101+
Specifies how to handle the same key appearing in more than one file. The default is
102+
:attr:`DuplicateKeysHandling.FAIL`, which treats the reload as failed and keeps the
103+
previously loaded overrides. :attr:`DuplicateKeysHandling.IGNORE` keeps the entry from
104+
the first configured file that defines the key and discards the others.
105+
106+
:param handling: the handling, as the enum or its string value (``"fail"`` or ``"ignore"``)
107+
"""
108+
self.__duplicate_keys_handling = DuplicateKeysHandling(handling)
109+
return self
110+
111+
def change_detection(self, mode: Union[ChangeDetection, str]) -> 'FileOverrideSourceBuilder':
112+
"""
113+
Selects how the source detects file changes. The default is
114+
:attr:`ChangeDetection.POLLING`. The two modes are alternatives, so setting one replaces
115+
the other. :attr:`ChangeDetection.WATCHING` requires the ``watchdog`` package.
116+
117+
:param mode: the mode, as the enum or its string value (``"polling"`` or ``"watching"``)
118+
"""
119+
self.__change_detection = ChangeDetection(mode)
120+
return self
121+
122+
def poll_interval(self, seconds: float) -> 'FileOverrideSourceBuilder':
123+
"""
124+
Sets the interval between examinations of the files in polling mode. Watching mode
125+
ignores it. The default is :attr:`DEFAULT_POLL_INTERVAL`. An interval below
126+
:attr:`MINIMUM_POLL_INTERVAL` is raised to the minimum.
127+
128+
:param seconds: the interval in seconds
129+
"""
130+
self.__poll_interval = seconds
131+
return self
132+
133+
def build(self, config: DataSourceBuilderConfig) -> OverrideSource: # pylint: disable=unused-argument
134+
"""
135+
Builds the override source. This is called internally by the SDK. It raises
136+
``ValueError`` when no file paths were specified or when watching mode was selected
137+
without the ``watchdog`` package.
138+
"""
139+
if len(self.__paths) == 0:
140+
raise ValueError("no file paths were specified for the file-based override source")
141+
if self.__change_detection == ChangeDetection.WATCHING and not have_watchdog:
142+
raise ValueError("the file-based override source cannot watch files for changes because the watchdog package is not installed; install it or use polling")
143+
144+
poll_interval = self.__poll_interval
145+
if self.__change_detection == ChangeDetection.POLLING and poll_interval < self.MINIMUM_POLL_INTERVAL:
146+
log.warning("Poll interval %s is below the minimum for the file-based override source; using %s", poll_interval, self.MINIMUM_POLL_INTERVAL)
147+
poll_interval = self.MINIMUM_POLL_INTERVAL
148+
149+
return _FileOverrideSource(abs_file_paths(self.__paths), self.__duplicate_keys_handling, self.__change_detection, poll_interval)
150+
151+
152+
__all__ = ['ChangeDetection', 'DuplicateKeysHandling', 'FileOverrideSourceBuilder']

0 commit comments

Comments
 (0)