Skip to content

Commit 581fcfb

Browse files
authored
feat: Add postfork method to re-initialize after forking (#329)
1 parent b7145ea commit 581fcfb

File tree

8 files changed

+110
-3
lines changed

8 files changed

+110
-3
lines changed

contract-tests/service.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ def status():
7474
'event-sampling',
7575
'polling-gzip',
7676
'inline-context-all',
77+
'instance-id',
7778
'anonymous-redaction',
7879
'evaluation-hooks',
7980
'omit-anonymous-contexts',

ldclient/client.py

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import threading
88
import traceback
99
from typing import Any, Callable, Dict, List, Mapping, Optional, Tuple
10+
from uuid import uuid4
1011

1112
from ldclient.config import Config
1213
from ldclient.context import Context
@@ -188,15 +189,43 @@ def __init__(self, config: Config, start_wait: float = 5):
188189
check_uwsgi()
189190

190191
self._config = config
192+
self._config._instance_id = str(uuid4())
191193
self._config._validate()
192194

193-
self.__hooks_lock = ReadWriteLock()
194-
self.__hooks = config.hooks # type: List[Hook]
195-
196195
self._event_processor = None
197196
self._event_factory_default = EventFactory(False)
198197
self._event_factory_with_reasons = EventFactory(True)
199198

199+
self.__start_up(start_wait)
200+
201+
def postfork(self, start_wait: float = 5):
202+
"""
203+
Re-initializes an existing client after a process fork.
204+
205+
The SDK relies on multiple background threads to operate correctly.
206+
When a process forks, `these threads are not available to the child
207+
<https://pythondev.readthedocs.io/fork.html#reinitialize-all-locks-after-fork>`.
208+
209+
As a result, the SDK will not function correctly in the child process
210+
until it is re-initialized.
211+
212+
This method is effectively equivalent to instantiating a new client.
213+
Future iterations of the SDK will provide increasingly efficient
214+
re-initializing improvements.
215+
216+
Note that any configuration provided to the SDK will need to survive
217+
the forking process independently. For this reason, it is recommended
218+
that any listener or hook integrations be added postfork unless you are
219+
certain it can survive the forking process.
220+
221+
:param start_wait: the number of seconds to wait for a successful connection to LaunchDarkly
222+
"""
223+
self.__start_up(start_wait)
224+
225+
def __start_up(self, start_wait: float):
226+
self.__hooks_lock = ReadWriteLock()
227+
self.__hooks = self._config.hooks # type: List[Hook]
228+
200229
data_store_listeners = Listeners()
201230
store_sink = DataStoreUpdateSinkImpl(data_store_listeners)
202231
store = _FeatureStoreClientWrapper(self._config.feature_store, store_sink)

ldclient/config.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -289,6 +289,7 @@ def __init__(
289289
self.__omit_anonymous_contexts = omit_anonymous_contexts
290290
self.__payload_filter_key = payload_filter_key
291291
self._data_source_update_sink: Optional[DataSourceUpdateSink] = None
292+
self._instance_id: Optional[str] = None
292293

293294
def copy_with_new_sdk_key(self, new_sdk_key: str) -> 'Config':
294295
"""Returns a new ``Config`` instance that is the same as this one, except for having a different SDK key.

ldclient/impl/http.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,9 @@ def _application_header_value(application: dict) -> str:
2525
def _base_headers(config):
2626
headers = {'Authorization': config.sdk_key or '', 'User-Agent': 'PythonClient/' + VERSION}
2727

28+
if config._instance_id is not None:
29+
headers['X-LaunchDarkly-Instance-Id'] = config._instance_id
30+
2831
app_value = _application_header_value(config.application)
2932
if app_value:
3033
headers['X-LaunchDarkly-Tags'] = app_value

ldclient/testing/impl/datasource/test_feature_requester.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,21 @@ def test_get_all_data_sends_headers():
3737
assert req.headers['Accept-Encoding'] == 'gzip'
3838
assert req.headers.get('X-LaunchDarkly-Wrapper') is None
3939
assert req.headers.get('X-LaunchDarkly-Tags') is None
40+
assert req.headers.get('X-LaunchDarkly-Instance-Id') is None
41+
42+
43+
def test_sets_instance_id_header():
44+
with start_server() as server:
45+
config = Config(sdk_key='sdk-key', base_uri=server.uri)
46+
config._instance_id = 'my-instance-id'
47+
fr = FeatureRequesterImpl(config)
48+
49+
resp_data = {'flags': {}, 'segments': {}}
50+
server.for_path('/sdk/latest-all', JsonResponse(resp_data))
51+
52+
fr.get_all_data()
53+
req = server.require_request()
54+
assert req.headers.get('X-LaunchDarkly-Instance-Id') == 'my-instance-id'
4055

4156

4257
def test_get_all_data_sends_wrapper_header():

ldclient/testing/impl/datasource/test_streaming.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,9 +50,26 @@ def test_request_properties():
5050
assert req.headers.get('Authorization') == 'sdk-key'
5151
assert req.headers.get('User-Agent') == 'PythonClient/' + VERSION
5252
assert req.headers.get('X-LaunchDarkly-Wrapper') is None
53+
assert req.headers.get('X-LaunchDarkly-Instance-Id') is None
5354
assert req.headers.get('X-LaunchDarkly-Tags') is None
5455

5556

57+
def test_sends_instance_id():
58+
store = InMemoryFeatureStore()
59+
ready = Event()
60+
61+
with start_server() as server:
62+
with stream_content(make_put_event()) as stream:
63+
config = Config(sdk_key='sdk-key', stream_uri=server.uri, wrapper_name='Flask', wrapper_version='0.1.0')
64+
config._instance_id = 'my-instance-id'
65+
server.for_path('/all', stream)
66+
67+
with StreamingUpdateProcessor(config, store, ready, None) as sp:
68+
sp.start()
69+
req = server.await_request()
70+
assert req.headers.get('X-LaunchDarkly-Instance-Id') == 'my-instance-id'
71+
72+
5673
def test_sends_wrapper_header():
5774
store = InMemoryFeatureStore()
5875
ready = Event()

ldclient/testing/impl/events/test_event_processor.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,14 @@ def __init__(self, **kwargs):
6060
kwargs['diagnostic_opt_out'] = True
6161
if 'sdk_key' not in kwargs:
6262
kwargs['sdk_key'] = 'SDK_KEY'
63+
64+
instance_id = None
65+
if 'instance_id' in kwargs:
66+
instance_id = kwargs['instance_id']
67+
del kwargs['instance_id']
68+
6369
config = Config(**kwargs)
70+
config._instance_id = instance_id
6471
diagnostic_accumulator = _DiagnosticAccumulator(create_diagnostic_id(config))
6572
DefaultEventProcessor.__init__(self, config, mock_http, diagnostic_accumulator=diagnostic_accumulator)
6673

@@ -572,6 +579,24 @@ def test_wrapper_header_sent_when_set():
572579
assert mock_http.request_headers.get('X-LaunchDarkly-Wrapper') == "Flask/0.0.1"
573580

574581

582+
def test_instance_id_header_not_sent_when_not_set():
583+
with DefaultTestProcessor() as ep:
584+
ep.send_event(EventInputIdentify(timestamp, context))
585+
ep.flush()
586+
ep._wait_until_inactive()
587+
588+
assert mock_http.request_headers.get('X-LaunchDarkly-Wrapper') is None
589+
590+
591+
def test_instance_id_header_sent_when_set():
592+
with DefaultTestProcessor(instance_id="my-instance-id") as ep:
593+
ep.send_event(EventInputIdentify(timestamp, context))
594+
ep.flush()
595+
ep._wait_until_inactive()
596+
597+
assert mock_http.request_headers.get('X-LaunchDarkly-Instance-Id') == "my-instance-id"
598+
599+
575600
def test_wrapper_header_sent_without_version():
576601
with DefaultTestProcessor(wrapper_name="Flask") as ep:
577602
ep.send_event(EventInputIdentify(timestamp, context))

ldclient/testing/test_ldclient_end_to_end.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,22 @@
1515
always_true_flag = {'key': 'flagkey', 'version': 1, 'on': False, 'offVariation': 1, 'variations': [False, True]}
1616

1717

18+
def test_config_ignores_initial_instance_id():
19+
with start_server() as stream_server:
20+
with stream_content(make_put_event([always_true_flag])) as stream_handler:
21+
stream_server.for_path('/all', stream_handler)
22+
config = Config(sdk_key=sdk_key, stream_uri=stream_server.uri, send_events=False)
23+
config._instance_id = "Hey, I'm not supposed to modify this"
24+
25+
with LDClient(config=config) as client:
26+
assert client.is_initialized()
27+
assert client.variation(always_true_flag['key'], user, False) is True
28+
29+
r = stream_server.await_request()
30+
assert r.headers['X-LaunchDarkly-Instance-Id'] == config._instance_id
31+
assert r.headers['X-LaunchDarkly-Instance-Id'] != "Hey, I'm not supposed to modify this"
32+
33+
1834
def test_client_starts_in_streaming_mode():
1935
with start_server() as stream_server:
2036
with stream_content(make_put_event([always_true_flag])) as stream_handler:

0 commit comments

Comments
 (0)