Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 2 additions & 3 deletions ldclient/feature_store_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,8 @@

from typing import Any, Dict, Mapping

from expiringdict import ExpiringDict

from ldclient.feature_store import CacheConfig
from ldclient.impl.expiring_cache import ExpiringCache
from ldclient.impl.util import log
from ldclient.interfaces import (
DiagnosticDescription,
Expand Down Expand Up @@ -66,7 +65,7 @@ def __init__(self, cache_config: CacheConfig):
:param cache_config: the caching parameters
"""
if cache_config.enabled:
self._cache = ExpiringDict(max_len=cache_config.capacity, max_age_seconds=cache_config.expiration)
self._cache = ExpiringCache(max_len=cache_config.capacity, max_age_seconds=cache_config.expiration)
else:
self._cache = _NOOP_CACHE
self._inited = False
Expand Down
5 changes: 2 additions & 3 deletions ldclient/impl/async_big_segments.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
import asyncio
from typing import Awaitable, Callable, Optional, Tuple

from expiringdict import ExpiringDict

from ldclient.async_config import AsyncBigSegmentsConfig
from ldclient.evaluation import BigSegmentsStatus
from ldclient.impl.aio.concurrency import AsyncRepeatingTask
Expand All @@ -11,6 +9,7 @@
_hash_for_user_key,
is_stale
)
from ldclient.impl.expiring_cache import ExpiringCache
from ldclient.impl.listeners import Listeners
from ldclient.impl.util import log
from ldclient.interfaces import (
Expand Down Expand Up @@ -66,7 +65,7 @@ def __init__(self, config: AsyncBigSegmentsConfig):
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.__cache = ExpiringCache(max_len=config.context_cache_size, max_age_seconds=config.context_cache_time)
self.__poll_task = AsyncRepeatingTask.at_interval("ldclient.bigsegment.status-poll", config.status_poll_interval, 0, self.poll_store_and_update_status)

def start(self):
Expand Down
5 changes: 2 additions & 3 deletions ldclient/impl/big_segments.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,14 @@
from threading import Lock
from typing import Callable, Optional, Tuple

from expiringdict import ExpiringDict

from ldclient.config import BigSegmentsConfig
from ldclient.evaluation import BigSegmentsStatus
from ldclient.impl.big_segments_common import (
EMPTY_MEMBERSHIP,
_hash_for_user_key,
is_stale
)
from ldclient.impl.expiring_cache import ExpiringCache
from ldclient.impl.listeners import Listeners
from ldclient.impl.repeating_task import RepeatingTask
from ldclient.impl.util import log
Expand Down Expand Up @@ -68,7 +67,7 @@ def __init__(self, config: BigSegmentsConfig):
self.__poll_task = None # type: Optional[RepeatingTask]

if self.__store:
self.__cache = ExpiringDict(max_len=config.context_cache_size, max_age_seconds=config.context_cache_time)
self.__cache = ExpiringCache(max_len=config.context_cache_size, max_age_seconds=config.context_cache_time)
self.__poll_task = RepeatingTask.at_interval("ldclient.bigsegment.status-poll", config.status_poll_interval, 0, self.poll_store_and_update_status)
self.__poll_task.start()

Expand Down
66 changes: 66 additions & 0 deletions ldclient/impl/expiring_cache.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import time
from collections import OrderedDict
from threading import Lock
from typing import Any, Callable, Hashable, Optional, Tuple


class ExpiringCache:
"""A thread-safe cache with a maximum size and a maximum entry age.

An entry expires ``max_age_seconds`` after it was last written. When the cache is full, a new
key evicts the entry that was written first. Reads do not change the eviction order.
"""

def __init__(self, max_len: int, max_age_seconds: float, clock: Callable[[], float] = time.monotonic):
"""
:param max_len: the maximum number of entries; must be at least 1
:param max_age_seconds: the time after a write at which an entry expires
:param clock: returns the current time in seconds
"""
if max_len < 1:
raise ValueError("max_len must be at least 1")
self._max_len = max_len
self._max_age = max_age_seconds
self._clock = clock
self._data: 'OrderedDict[Hashable, Tuple[Any, float]]' = OrderedDict()
self._lock = Lock()

def get(self, key: Hashable, default: Optional[Any] = None) -> Any:
"""Returns the value for the key, or ``default`` if the key is missing or expired."""
with self._lock:
entry = self._data.get(key)
if entry is None:
return default
value, written_at = entry
if self._clock() - written_at >= self._max_age:
del self._data[key]
return default
return value

def __setitem__(self, key: Hashable, value: Any) -> None:
with self._lock:
if key in self._data:
Comment thread
jsonbailey marked this conversation as resolved.
Resolved
del self._data[key]
elif len(self._data) >= self._max_len:
self._data.popitem(last=False)
self._data[key] = (value, self._clock())

def pop(self, key: Hashable, default: Optional[Any] = None) -> Any:
"""Removes the key and returns its value, or ``default`` if the key is missing or expired."""
with self._lock:
entry = self._data.pop(key, None)
if entry is None:
return default
value, written_at = entry
if self._clock() - written_at >= self._max_age:
return default
return value

def clear(self) -> None:
with self._lock:
self._data.clear()

def __len__(self) -> int:
"""Returns the number of stored entries, including expired entries not yet removed."""
with self._lock:
return len(self._data)
2 changes: 1 addition & 1 deletion ldclient/testing/impl/datasystem/test_cache_lifecycle.py
Original file line number Diff line number Diff line change
Expand Up @@ -228,7 +228,7 @@ def test_init_after_disable_skips_decode_loop(self):
assert wrapper._inited is True

def test_disable_cache_drops_entries_from_old_cache(self):
"""The old ExpiringDict must be empty after disable so its entries get GC'd."""
"""The old cache must be empty after disable so its entries get GC'd."""
core = RecordingCore()
core.data = {FEATURES: {"flag-1": _flag("flag-1")}}
wrapper = CachingStoreWrapper(core, CacheConfig.default())
Expand Down
Loading
Loading