-
Notifications
You must be signed in to change notification settings - Fork 47
feat: Add async big segment store manager and async Redis adapter #462
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
jsonbailey
wants to merge
8
commits into
main
Choose a base branch
from
jb/sdk-2729/async-big-segments
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
8 commits
Select commit
Hold shift + click to select a range
1e86a17
feat: Add async big segment store manager and async Redis adapter
jsonbailey c490639
fix: Do not cap the async Redis big segment store connection pool
jsonbailey 47e12bd
refactor: Extract shared BigSegmentStoreStatusProviderImpl into big_s…
jsonbailey d73d129
refactor: Align async big-segments manager method order with sync
jsonbailey eb1c4d1
refactor: Move shared big-segments helpers into big_segments_common
jsonbailey e428549
refactor: Use EMPTY_MEMBERSHIP module constant directly
jsonbailey 7bd5ebd
build: Drop speculative redis upper version bound
jsonbailey 45a2125
build: Keep published redis floor at >=2.10.5 (bump only dev/test)
jsonbailey File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,65 @@ | ||
| """ | ||
| Configuration types for the async SDK client. Currently this holds | ||
| :class:`AsyncBigSegmentsConfig`; the full :class:`AsyncConfig` lands in a later slice. | ||
|
|
||
| .. caution:: | ||
| This feature is experimental and should NOT be considered ready for production | ||
| use. It may change or be removed without notice and is not subject to backwards | ||
| compatibility guarantees. | ||
| """ | ||
|
|
||
| from typing import Optional | ||
|
|
||
| from ldclient.interfaces import AsyncBigSegmentStore | ||
|
|
||
|
|
||
| class AsyncBigSegmentsConfig: | ||
| """Configuration options related to Big Segments for the async SDK client. | ||
|
|
||
| Big Segments are a specific type of segments. For more information, read the LaunchDarkly | ||
| documentation: https://docs.launchdarkly.com/home/users/big-segments | ||
|
|
||
| If your application uses Big Segments, you will need to create an ``AsyncBigSegmentsConfig`` | ||
| that at a minimum specifies what database integration to use, and then pass the | ||
| ``AsyncBigSegmentsConfig`` object as the ``big_segments`` parameter when creating an | ||
| :class:`AsyncConfig`. | ||
| """ | ||
|
|
||
| def __init__(self, store: Optional[AsyncBigSegmentStore] = None, context_cache_size: int = 1000, context_cache_time: float = 5, status_poll_interval: float = 5, stale_after: float = 120): | ||
| """ | ||
| :param store: the implementation of :class:`ldclient.interfaces.AsyncBigSegmentStore` that | ||
| will be used to query the Big Segments database | ||
| :param context_cache_size: the maximum number of contexts whose Big Segment state will be cached | ||
| by the SDK at any given time | ||
| :param context_cache_time: the maximum length of time (in seconds) that the Big Segment state | ||
| for a context will be cached by the SDK | ||
| :param status_poll_interval: the interval (in seconds) at which the SDK will poll the Big | ||
| Segment store to make sure it is available and to determine how long ago it was updated | ||
| :param stale_after: the maximum length of time between updates of the Big Segments data | ||
| before the data is considered out of date | ||
| """ | ||
| self.__store = store | ||
| self.__context_cache_size = context_cache_size | ||
| self.__context_cache_time = context_cache_time | ||
| self.__status_poll_interval = status_poll_interval | ||
| self.__stale_after = stale_after | ||
|
|
||
| @property | ||
| def store(self) -> Optional[AsyncBigSegmentStore]: | ||
| return self.__store | ||
|
|
||
| @property | ||
| def context_cache_size(self) -> int: | ||
| return self.__context_cache_size | ||
|
|
||
| @property | ||
| def context_cache_time(self) -> float: | ||
| return self.__context_cache_time | ||
|
|
||
| @property | ||
| def status_poll_interval(self) -> float: | ||
| return self.__status_poll_interval | ||
|
|
||
| @property | ||
| def stale_after(self) -> float: | ||
| return self.__stale_after |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,98 @@ | ||
| from typing import Optional, Tuple | ||
|
|
||
| from expiringdict import ExpiringDict | ||
|
|
||
| from ldclient.async_config import AsyncBigSegmentsConfig | ||
| from ldclient.evaluation import BigSegmentsStatus | ||
| from ldclient.impl.aio.concurrency import AsyncRepeatingTask | ||
| from ldclient.impl.big_segments_common import ( | ||
| EMPTY_MEMBERSHIP, | ||
| BigSegmentStoreStatusProviderImpl, | ||
| _hash_for_user_key, | ||
| is_stale | ||
| ) | ||
| from ldclient.impl.util import log | ||
| from ldclient.interfaces import ( | ||
| BigSegmentStoreStatus, | ||
| BigSegmentStoreStatusProvider | ||
| ) | ||
|
|
||
|
|
||
| class AsyncBigSegmentStoreManager: | ||
| """ | ||
| Internal component that decorates the Big Segment store with caching behavior, and also polls the | ||
| store to track its status. The constructor starts the polling task. | ||
| """ | ||
|
|
||
| # Because the constructor starts the polling task, it must run within a running event loop. | ||
| def __init__(self, config: AsyncBigSegmentsConfig): | ||
| self.__store = config.store | ||
|
|
||
| self.__stale_after_millis = config.stale_after * 1000 | ||
| self.__status_provider = BigSegmentStoreStatusProviderImpl(self.get_status) | ||
| self.__last_status = None # type: Optional[BigSegmentStoreStatus] | ||
| self.__poll_task = None # type: Optional[AsyncRepeatingTask] | ||
|
|
||
| if self.__store: | ||
| self.__cache = ExpiringDict(max_len=config.context_cache_size, max_age_seconds=config.context_cache_time) | ||
| self.__poll_task = AsyncRepeatingTask("ldclient.bigsegment.status-poll", config.status_poll_interval, 0, self.poll_store_and_update_status) | ||
| self.__poll_task.start() | ||
|
|
||
| async def stop(self): | ||
| if self.__poll_task: | ||
| self.__poll_task.stop() | ||
| if self.__store: | ||
| await self.__store.stop() | ||
|
|
||
| @property | ||
| def status_provider(self) -> BigSegmentStoreStatusProvider: | ||
| return self.__status_provider | ||
|
|
||
| async def get_user_membership(self, user_key: str) -> Tuple[Optional[dict], str]: | ||
| if not self.__store: | ||
| return None, BigSegmentsStatus.NOT_CONFIGURED | ||
| membership = self.__cache.get(user_key) | ||
| if membership is None: | ||
| user_hash = _hash_for_user_key(user_key) | ||
| try: | ||
| membership = await self.__store.get_membership(user_hash) # type: ignore[misc] | ||
| if membership is None: | ||
| membership = EMPTY_MEMBERSHIP | ||
| self.__cache[user_key] = membership | ||
| except Exception as e: | ||
| log.exception("Big Segment store membership query returned error: %s" % e) | ||
| return None, BigSegmentsStatus.STORE_ERROR | ||
| # First-call fallback: if the polling task hasn't run yet, poll inline now | ||
| status = self.__last_status | ||
| if status is None: | ||
| status = await self.poll_store_and_update_status() | ||
| if not status.available: | ||
| return membership, BigSegmentsStatus.STORE_ERROR | ||
| return membership, BigSegmentsStatus.STALE if status.stale else BigSegmentsStatus.HEALTHY | ||
|
|
||
| def get_status(self) -> BigSegmentStoreStatus: | ||
| """Return the most recently polled status. | ||
|
|
||
| When no status has been cached yet, the sync variant polls the store | ||
| inline; the async variant (whose status getter cannot await) reports | ||
| the store as unavailable until the polling task has run. | ||
| """ | ||
| status = self.__last_status | ||
| if status is None: | ||
| return BigSegmentStoreStatus(False, False) | ||
| return status if status else self.poll_store_and_update_status() # type: ignore[return-value] | ||
|
|
||
| async def poll_store_and_update_status(self) -> BigSegmentStoreStatus: | ||
| new_status = BigSegmentStoreStatus(False, False) # default to "unavailable" if we don't get a new status below | ||
| if self.__store: | ||
| try: | ||
| metadata = await self.__store.get_metadata() # type: ignore[misc] | ||
| new_status = BigSegmentStoreStatus(True, (metadata is None) or self.is_stale(metadata.last_up_to_date)) | ||
| except Exception as e: | ||
| log.exception("Big Segment store status query returned error: %s" % e) | ||
| self.__last_status = new_status | ||
| self.__status_provider._update_status(new_status) | ||
| return new_status | ||
|
|
||
| def is_stale(self, timestamp) -> bool: | ||
| return is_stale(timestamp, self.__stale_after_millis) | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,63 @@ | ||
| """ | ||
| Shared, I/O-free big-segments status provider used by both the sync | ||
| :mod:`ldclient.impl.big_segments` and the async | ||
| :mod:`ldclient.impl.async_big_segments`. It holds the last known status and | ||
| notifies listeners; nothing here touches the store or network, so it is | ||
| identical across the two managers. | ||
| """ | ||
|
|
||
| import base64 | ||
| import time | ||
| from hashlib import sha256 | ||
| from typing import Callable, Optional | ||
|
|
||
| from ldclient.impl.listeners import Listeners | ||
| from ldclient.interfaces import ( | ||
| BigSegmentStoreStatus, | ||
| BigSegmentStoreStatusProvider | ||
| ) | ||
|
|
||
| # use EMPTY_MEMBERSHIP as a singleton whenever a membership query returns None; it's safe to reuse it | ||
| # because we will never modify the membership properties after they're queried | ||
| EMPTY_MEMBERSHIP = {} # type: dict | ||
|
|
||
|
|
||
| def _hash_for_user_key(user_key: str) -> str: | ||
| return base64.b64encode(sha256(user_key.encode('utf-8')).digest()).decode('utf-8') | ||
|
|
||
|
|
||
| def is_stale(timestamp: int, stale_after_millis) -> bool: | ||
| return (timestamp is None) or ((int(time.time() * 1000) - timestamp) >= stale_after_millis) | ||
|
|
||
|
|
||
| class BigSegmentStoreStatusProviderImpl(BigSegmentStoreStatusProvider): | ||
| """ | ||
| Default implementation of the BigSegmentStoreStatusProvider interface. | ||
|
|
||
| The real implementation of getting the status is in the big segment store manager - we pass in a lambda that | ||
| allows us to get the current status from that class. So this class provides a facade for that, and | ||
| also adds the listener mechanism. | ||
| """ | ||
|
|
||
| def __init__(self, status_getter: Callable[[], BigSegmentStoreStatus]): | ||
| self.__status_getter = status_getter | ||
| self.__status_listeners = Listeners() | ||
| self.__last_status = None # type: Optional[BigSegmentStoreStatus] | ||
|
|
||
| @property | ||
| def status(self) -> BigSegmentStoreStatus: | ||
| return self.__status_getter() | ||
|
|
||
| def add_listener(self, listener: Callable[[BigSegmentStoreStatus], None]) -> None: | ||
| self.__status_listeners.add(listener) | ||
|
|
||
| def remove_listener(self, listener: Callable[[BigSegmentStoreStatus], None]) -> None: | ||
| self.__status_listeners.remove(listener) | ||
|
|
||
| def _update_status(self, new_status: BigSegmentStoreStatus): | ||
| last = self.__last_status | ||
| if last is None: | ||
| self.__last_status = new_status | ||
| elif new_status.available != last.available or new_status.stale != last.stale: | ||
| self.__last_status = new_status | ||
| self.__status_listeners.notify(new_status) |
41 changes: 41 additions & 0 deletions
41
ldclient/impl/integrations/redis/async_redis_big_segment_store.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,41 @@ | ||
| from typing import Any, Dict, Optional | ||
|
|
||
| from ldclient.impl.util import log, redact_password | ||
| from ldclient.interfaces import AsyncBigSegmentStore, BigSegmentStoreMetadata | ||
|
|
||
| have_async_redis = False | ||
| try: | ||
| import redis.asyncio as redis_client | ||
|
|
||
| have_async_redis = True | ||
| except ImportError: | ||
| pass | ||
|
|
||
|
|
||
| class _AsyncRedisBigSegmentStore(AsyncBigSegmentStore): | ||
| KEY_LAST_UP_TO_DATE = ':big_segments_synchronized_on' | ||
| KEY_USER_INCLUDE = ':big_segment_include:' | ||
| KEY_USER_EXCLUDE = ':big_segment_exclude:' | ||
|
|
||
| def __init__(self, url: str, prefix: Optional[str], redis_opts: Dict[str, Any]): | ||
| if not have_async_redis: | ||
| raise NotImplementedError("Cannot use async Redis Big Segment store because redis package is not installed") | ||
| self._prefix = prefix or 'launchdarkly' | ||
| self._client = redis_client.from_url(url, **redis_opts) | ||
| log.info("Started AsyncRedisBigSegmentStore connected to URL: " + redact_password(url) + " using prefix: " + self._prefix) | ||
|
|
||
| async def get_metadata(self) -> BigSegmentStoreMetadata: | ||
| value = await self._client.get(self._prefix + self.KEY_LAST_UP_TO_DATE) | ||
| return BigSegmentStoreMetadata(int(value) if value else None) | ||
|
|
||
| async def get_membership(self, user_hash: str) -> Optional[dict]: | ||
| included = await self._client.smembers(self._prefix + self.KEY_USER_INCLUDE + user_hash) | ||
| excluded = await self._client.smembers(self._prefix + self.KEY_USER_EXCLUDE + user_hash) | ||
| if not included and not excluded: | ||
| return None | ||
| ret = {ref.decode(): False for ref in excluded} | ||
| ret.update({ref.decode(): True for ref in included}) | ||
| return ret | ||
|
|
||
| async def stop(self): | ||
| await self._client.aclose() |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
get_status returns unawaited coroutine
Low Severity
In
get_status, the fallback calls asyncpoll_store_and_update_statuswithoutawait, so that branch would return a coroutine object instead of aBigSegmentStoreStatus. The branch is unreachable with currentBigSegmentStoreStatusobjects, but it mirrors the sync manager incorrectly and is masked by a type ignore.Reviewed by Cursor Bugbot for commit 7bd5ebd. Configure here.