Skip to content

Commit 66013b5

Browse files
authored
fix: Reuse an in-flight big segment status poll instead of always querying twice (#535)
1 parent 4d75813 commit 66013b5

4 files changed

Lines changed: 123 additions & 15 deletions

File tree

‎ldclient/impl/async_big_segments.py‎

Lines changed: 27 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import asyncio
12
from typing import Awaitable, Callable, Optional, Tuple
23

34
from expiringdict import ExpiringDict
@@ -61,6 +62,7 @@ def __init__(self, config: AsyncBigSegmentsConfig):
6162
self.__stale_after_millis = config.stale_after * 1000
6263
self.__status_provider = AsyncBigSegmentStoreStatusProviderImpl(self.get_status)
6364
self.__last_status = None # type: Optional[BigSegmentStoreStatus]
65+
self.__poll_lock = asyncio.Lock()
6466
self.__poll_task = None # type: Optional[AsyncRepeatingTask]
6567

6668
if self.__store:
@@ -97,26 +99,37 @@ async def get_user_membership(self, user_key: str) -> Tuple[Optional[dict], str]
9799
except Exception as e:
98100
log.exception("Big Segment store membership query returned error: %s" % e)
99101
return None, BigSegmentsStatus.STORE_ERROR
100-
# First-call fallback: if the polling task hasn't run yet, poll inline now
101-
status = self.__last_status
102-
if status is None:
103-
status = await self.poll_store_and_update_status()
102+
status = await self.get_status()
104103
if not status.available:
105104
return membership, BigSegmentsStatus.STORE_ERROR
106105
return membership, BigSegmentsStatus.STALE if status.stale else BigSegmentsStatus.HEALTHY
107106

108107
async def get_status(self) -> BigSegmentStoreStatus:
109108
"""Return the most recently polled status.
110109
111-
When no status has been cached yet, poll the store inline and wait for the
112-
result, so the status is accurate even if called immediately after start().
110+
When no status has been cached yet, poll the store and wait for the result,
111+
so the status is accurate even if called immediately after start().
113112
"""
114113
status = self.__last_status
115-
if status is None:
116-
status = await self.poll_store_and_update_status()
117-
return status
114+
if status is not None:
115+
return status
116+
# Check again under the lock: another caller may have polled while we waited.
117+
async with self.__poll_lock:
118+
status = self.__last_status
119+
if status is not None:
120+
return status
121+
new_status = await self.__query_store_status()
122+
self.__notify_status(new_status)
123+
return new_status
118124

119125
async def poll_store_and_update_status(self) -> BigSegmentStoreStatus:
126+
async with self.__poll_lock:
127+
new_status = await self.__query_store_status()
128+
self.__notify_status(new_status)
129+
return new_status
130+
131+
async def __query_store_status(self) -> BigSegmentStoreStatus:
132+
"""Queries the store and caches the result. Callers must hold the poll lock."""
120133
new_status = BigSegmentStoreStatus(False, False) # default to "unavailable" if we don't get a new status below
121134
if self.__store:
122135
try:
@@ -125,8 +138,12 @@ async def poll_store_and_update_status(self) -> BigSegmentStoreStatus:
125138
except Exception as e:
126139
log.exception("Big Segment store status query returned error: %s" % e)
127140
self.__last_status = new_status
128-
self.__status_provider._update_status(new_status)
129141
return new_status
130142

143+
def __notify_status(self, new_status: BigSegmentStoreStatus):
144+
"""Tells the status provider about a new status, outside the poll lock so
145+
that a listener calling back into the manager cannot deadlock."""
146+
self.__status_provider._update_status(new_status)
147+
131148
def is_stale(self, timestamp) -> bool:
132149
return is_stale(timestamp, self.__stale_after_millis)

‎ldclient/impl/big_segments.py‎

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
from threading import Lock
12
from typing import Callable, Optional, Tuple
23

34
from expiringdict import ExpiringDict
@@ -63,6 +64,7 @@ def __init__(self, config: BigSegmentsConfig):
6364
self.__stale_after_millis = config.stale_after * 1000
6465
self.__status_provider = BigSegmentStoreStatusProviderImpl(self.get_status)
6566
self.__last_status = None # type: Optional[BigSegmentStoreStatus]
67+
self.__poll_lock = Lock()
6668
self.__poll_task = None # type: Optional[RepeatingTask]
6769

6870
if self.__store:
@@ -94,18 +96,32 @@ def get_user_membership(self, user_key: str) -> Tuple[Optional[dict], str]:
9496
except Exception as e:
9597
log.exception("Big Segment store membership query returned error: %s" % e)
9698
return None, BigSegmentsStatus.STORE_ERROR
97-
status = self.__last_status
98-
if not status:
99-
status = self.poll_store_and_update_status()
99+
status = self.get_status()
100100
if not status.available:
101101
return membership, BigSegmentsStatus.STORE_ERROR
102102
return membership, BigSegmentsStatus.STALE if status.stale else BigSegmentsStatus.HEALTHY
103103

104104
def get_status(self) -> BigSegmentStoreStatus:
105105
status = self.__last_status
106-
return status if status else self.poll_store_and_update_status()
106+
if status is not None:
107+
return status
108+
# Check again under the lock: another caller may have polled while we waited.
109+
with self.__poll_lock:
110+
status = self.__last_status
111+
if status is not None:
112+
return status
113+
new_status = self.__query_store_status()
114+
self.__notify_status(new_status)
115+
return new_status
107116

108117
def poll_store_and_update_status(self) -> BigSegmentStoreStatus:
118+
with self.__poll_lock:
119+
new_status = self.__query_store_status()
120+
self.__notify_status(new_status)
121+
return new_status
122+
123+
def __query_store_status(self) -> BigSegmentStoreStatus:
124+
"""Queries the store and caches the result. Callers must hold the poll lock."""
109125
new_status = BigSegmentStoreStatus(False, False) # default to "unavailable" if we don't get a new status below
110126
if self.__store:
111127
try:
@@ -114,8 +130,12 @@ def poll_store_and_update_status(self) -> BigSegmentStoreStatus:
114130
except Exception as e:
115131
log.exception("Big Segment store status query returned error: %s" % e)
116132
self.__last_status = new_status
117-
self.__status_provider._update_status(new_status)
118133
return new_status
119134

135+
def __notify_status(self, new_status: BigSegmentStoreStatus):
136+
"""Tells the status provider about a new status, outside the poll lock so
137+
that a listener calling back into the manager cannot deadlock."""
138+
self.__status_provider._update_status(new_status)
139+
120140
def is_stale(self, timestamp) -> bool:
121141
return is_stale(timestamp, self.__stale_after_millis)

‎ldclient/testing/impl/test_async_big_segments.py‎

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,10 +26,17 @@ class MockAsyncBigSegmentStore(AsyncBigSegmentStore):
2626

2727
def __init__(self):
2828
self._membership_queries = []
29+
self._metadata_queries = []
2930
self._memberships = {}
3031
self._metadata_fn = lambda: BigSegmentStoreMetadata(int(time.time() * 1000))
32+
self._metadata_delay = 0.0
3133
self._stopped = False
3234

35+
def setup_metadata_delay(self, delay: float):
36+
"""Makes get_metadata await for the given number of seconds, so a test can
37+
keep a metadata query in flight."""
38+
self._metadata_delay = delay
39+
3340
def setup_membership(self, user_hash: str, membership):
3441
self._memberships[user_hash] = membership
3542

@@ -48,6 +55,9 @@ def _raise():
4855
self._metadata_fn = _raise
4956

5057
async def get_metadata(self) -> BigSegmentStoreMetadata:
58+
self._metadata_queries.append(True)
59+
if self._metadata_delay:
60+
await asyncio.sleep(self._metadata_delay)
5161
return self._metadata_fn()
5262

5363
async def get_membership(self, context_hash: str):
@@ -61,6 +71,10 @@ async def stop(self):
6171
def membership_queries(self):
6272
return list(self._membership_queries)
6373

74+
@property
75+
def metadata_queries(self):
76+
return list(self._metadata_queries)
77+
6478

6579
async def make_started_manager(store, **kwargs):
6680
config = AsyncBigSegmentsConfig(store=store, **kwargs)
@@ -348,3 +362,30 @@ async def test_get_status_with_no_store_configured():
348362
assert status.available is False
349363
finally:
350364
await manager.stop()
365+
366+
367+
@pytest.mark.asyncio
368+
async def test_status_query_reuses_a_poll_that_is_already_in_flight():
369+
"""
370+
The polling task queries the store as soon as it starts. A status request that
371+
arrives while that query is still in flight reuses it rather than sending a second
372+
one, which is what the SDK does at startup.
373+
"""
374+
store = MockAsyncBigSegmentStore()
375+
store.setup_metadata_always_up_to_date()
376+
store.setup_metadata_delay(0.25)
377+
378+
manager = await make_started_manager(store, status_poll_interval=10)
379+
try:
380+
# Let the polling task start its query, so this request arrives while that
381+
# query is in flight and must wait for it instead of starting its own.
382+
await asyncio.sleep(0)
383+
assert store.metadata_queries, "polling task never queried the store"
384+
385+
status = await manager.get_status()
386+
assert status.available is True
387+
await asyncio.sleep(0.1) # let a second query show up, if the fix is not working
388+
finally:
389+
await manager.stop()
390+
391+
assert len(store.metadata_queries) == 1

‎ldclient/testing/impl/test_big_segments.py‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import time
22
from queue import Queue
3+
from threading import Event
34

45
from ldclient.config import BigSegmentsConfig
56
from ldclient.evaluation import BigSegmentsStatus
@@ -185,3 +186,32 @@ def test_status_polling_detects_stale_status():
185186
assert status3.stale is False
186187
finally:
187188
manager.stop()
189+
190+
191+
def test_status_query_reuses_a_poll_that_is_already_in_flight():
192+
# The polling task queries the store as soon as it starts. A status request
193+
# that arrives while that query is still in flight reuses it rather than
194+
# sending a second one, which is what the SDK does at startup.
195+
metadata_queries = []
196+
poll_started = Event()
197+
198+
def slow_metadata():
199+
metadata_queries.append(True)
200+
poll_started.set()
201+
time.sleep(0.25)
202+
return BigSegmentStoreMetadata(time.time() * 1000)
203+
204+
store = MockBigSegmentStore()
205+
store.setup_metadata(slow_metadata)
206+
207+
manager = BigSegmentStoreManager(BigSegmentsConfig(store=store, status_poll_interval=10))
208+
try:
209+
assert poll_started.wait(1.0), "polling task never queried the store"
210+
# The task's query is in flight now, so this request must wait for it
211+
# instead of starting its own.
212+
assert manager.status_provider.status.available is True
213+
time.sleep(0.1) # let a second query show up, if the fix is not working
214+
finally:
215+
manager.stop()
216+
217+
assert len(metadata_queries) == 1

0 commit comments

Comments
 (0)