Skip to content

Commit 33f0800

Browse files
feat: pause/start dcs (#474)
exposed API for people to control polling DCS
1 parent 80b7c4b commit 33f0800

File tree

4 files changed

+132
-5
lines changed

4 files changed

+132
-5
lines changed

statsig/spec_updater.py

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,9 @@ def __init__(
4646
self._statsig_metadata = statsig_metadata
4747
self._background_download_configs = None
4848
self._background_download_id_lists = None
49+
# DCS polling: set => polling enabled, clear => paused
50+
self._dcs_polling_enabled_event = threading.Event()
51+
self._dcs_polling_enabled_event.set()
4952
self._config_sync_strategies = self._get_sync_dcs_strategies()
5053
self._dcs_process_lock = threading.Lock()
5154
if options.out_of_sync_threshold_in_s is not None:
@@ -409,7 +412,7 @@ def sync_config_spec():
409412
self._background_download_configs = spawn_background_thread(
410413
"bg_download_config_specs",
411414
self._sync,
412-
(sync_config_spec, interval, fast_start),
415+
(sync_config_spec, interval, fast_start, self._dcs_polling_enabled_event),
413416
self._error_boundary,
414417
)
415418

@@ -422,18 +425,33 @@ def _spawn_bg_poll_id_lists(self):
422425
self._error_boundary,
423426
)
424427

425-
def _sync(self, sync_func, interval, fast_start=False):
426-
if fast_start:
428+
def _sync(
429+
self,
430+
sync_func,
431+
interval,
432+
fast_start=False,
433+
enabled_event: Optional[threading.Event] = None,
434+
):
435+
if fast_start and (enabled_event is None or enabled_event.is_set()):
427436
sync_func()
428437

429438
while True:
430439
try:
431-
if self._shutdown_event.wait(interval):
432-
break
440+
# Wait until next interval, unless shutdown is requested.
441+
if self._shutdown_event.wait(timeout=interval):
442+
return
443+
if enabled_event is not None and not enabled_event.is_set():
444+
continue
433445
sync_func()
434446
except Exception as e:
435447
self._error_boundary.log_exception("_sync", e)
436448

449+
def pause_polling_dcs(self):
450+
self._dcs_polling_enabled_event.clear()
451+
452+
def start_polling_dcs(self):
453+
self._dcs_polling_enabled_event.set()
454+
437455
def _get_sync_dcs_strategies(self) -> List[DataSource]:
438456
try:
439457
if self._options.config_sync_sources is not None:

statsig/statsig.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -327,6 +327,20 @@ def shutdown():
327327
__instance.shutdown()
328328

329329

330+
def pause_polling_dcs():
331+
"""
332+
Pauses background polling for Download Config Specs (DCS).
333+
"""
334+
__instance.pause_polling_dcs()
335+
336+
337+
def start_polling_dcs():
338+
"""
339+
Resumes background polling for Download Config Specs (DCS).
340+
"""
341+
__instance.start_polling_dcs()
342+
343+
330344
def get_instance():
331345
"""
332346
Returns the Statsig instance

statsig/statsig_server.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -415,6 +415,24 @@ def task():
415415

416416
self._errorBoundary.swallow("shutdown", task)
417417

418+
def pause_polling_dcs(self):
419+
def task():
420+
if not self._initialized:
421+
raise StatsigRuntimeError("Must call initialize before pausing DCS polling")
422+
if self._spec_store is not None:
423+
self._spec_store.spec_updater.pause_polling_dcs()
424+
425+
self._errorBoundary.swallow("pause_polling_dcs", task)
426+
427+
def start_polling_dcs(self):
428+
def task():
429+
if not self._initialized:
430+
raise StatsigRuntimeError("Must call initialize before starting DCS polling")
431+
if self._spec_store is not None:
432+
self._spec_store.spec_updater.start_polling_dcs()
433+
434+
self._errorBoundary.swallow("start_polling_dcs", task)
435+
418436
def override_gate(self, gate: str, value: bool, user_id: Optional[str] = None):
419437
self._errorBoundary.swallow(
420438
"override_gate", lambda: self._evaluator.override_gate(gate, value, user_id)

tests/test_pause_polling_dcs.py

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import json
2+
import os
3+
import time
4+
import unittest
5+
from unittest.mock import patch
6+
7+
from network_stub import NetworkStub
8+
from statsig import StatsigOptions, statsig
9+
10+
_network_stub = NetworkStub("http://test-pause-polling-dcs")
11+
12+
with open(os.path.join(os.path.abspath(os.path.dirname(__file__)), '../testdata/download_config_specs.json')) as r:
13+
CONFIG_SPECS_RESPONSE = r.read()
14+
PARSED_CONFIG_SPEC = json.loads(CONFIG_SPECS_RESPONSE)
15+
16+
17+
@patch('requests.Session.request', side_effect=_network_stub.mock)
18+
class TestPausePollingDcs(unittest.TestCase):
19+
@classmethod
20+
@patch('requests.Session.request', side_effect=_network_stub.mock)
21+
def setUpClass(cls, mock_proxy):
22+
cls.dcs_called = False
23+
cls.dcs_call_count = 0
24+
25+
def setUp(self):
26+
self.__class__.dcs_called = False
27+
self.__class__.dcs_call_count = 0
28+
29+
def dcs_cb(url, **kwargs):
30+
self.__class__.dcs_called = True
31+
self.__class__.dcs_call_count += 1
32+
return PARSED_CONFIG_SPEC
33+
34+
_network_stub.stub_request_with_value("get_id_lists", 200, {})
35+
_network_stub.stub_request_with_function("download_config_specs/.*", 200, dcs_cb)
36+
37+
def tearDown(self):
38+
statsig.shutdown()
39+
_network_stub.reset()
40+
41+
def test_pause_and_resume_dcs_polling(self, request_mock):
42+
options = StatsigOptions(api=_network_stub.host, rulesets_sync_interval=1)
43+
statsig.initialize("secret-key", options)
44+
45+
paused_wait_s = 3.0
46+
resume_timeout_s = 5.0
47+
48+
def wait_for_new_dcs_calls(prev_count: int, timeout_s: float) -> bool:
49+
deadline = time.time() + timeout_s
50+
while time.time() < deadline:
51+
if self.__class__.dcs_call_count > prev_count:
52+
return True
53+
time.sleep(0.05)
54+
return False
55+
56+
# ignore the initialize-time DCS fetch
57+
self.__class__.dcs_called = False
58+
self.__class__.dcs_call_count = 0
59+
60+
# OFF -> ON -> OFF -> ON
61+
statsig.pause_polling_dcs()
62+
prev = self.__class__.dcs_call_count
63+
time.sleep(paused_wait_s)
64+
self.assertEqual(prev, self.__class__.dcs_call_count)
65+
66+
statsig.start_polling_dcs()
67+
prev = self.__class__.dcs_call_count
68+
self.assertTrue(wait_for_new_dcs_calls(prev, timeout_s=resume_timeout_s))
69+
70+
statsig.pause_polling_dcs()
71+
prev = self.__class__.dcs_call_count
72+
time.sleep(paused_wait_s)
73+
self.assertEqual(prev, self.__class__.dcs_call_count)
74+
75+
statsig.start_polling_dcs()
76+
prev = self.__class__.dcs_call_count
77+
self.assertTrue(wait_for_new_dcs_calls(prev, timeout_s=resume_timeout_s))

0 commit comments

Comments
 (0)