diff --git a/ldclient/feature_store_helpers.py b/ldclient/feature_store_helpers.py index 19cec91c..39b874be 100644 --- a/ldclient/feature_store_helpers.py +++ b/ldclient/feature_store_helpers.py @@ -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, @@ -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 diff --git a/ldclient/impl/async_big_segments.py b/ldclient/impl/async_big_segments.py index ceff0a1f..3aba6408 100644 --- a/ldclient/impl/async_big_segments.py +++ b/ldclient/impl/async_big_segments.py @@ -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 @@ -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 ( @@ -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): diff --git a/ldclient/impl/big_segments.py b/ldclient/impl/big_segments.py index 8b0dd62c..f0e9467c 100644 --- a/ldclient/impl/big_segments.py +++ b/ldclient/impl/big_segments.py @@ -1,8 +1,6 @@ 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 ( @@ -10,6 +8,7 @@ _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 @@ -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() diff --git a/ldclient/impl/expiring_cache.py b/ldclient/impl/expiring_cache.py new file mode 100644 index 00000000..862dbd54 --- /dev/null +++ b/ldclient/impl/expiring_cache.py @@ -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: + 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) diff --git a/ldclient/testing/impl/datasystem/test_cache_lifecycle.py b/ldclient/testing/impl/datasystem/test_cache_lifecycle.py index 52e9cdc7..add1eaf7 100644 --- a/ldclient/testing/impl/datasystem/test_cache_lifecycle.py +++ b/ldclient/testing/impl/datasystem/test_cache_lifecycle.py @@ -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()) diff --git a/ldclient/testing/impl/test_expiring_cache.py b/ldclient/testing/impl/test_expiring_cache.py new file mode 100644 index 00000000..61d3f198 --- /dev/null +++ b/ldclient/testing/impl/test_expiring_cache.py @@ -0,0 +1,311 @@ +import threading +import time + +import pytest + +from ldclient.impl.expiring_cache import ExpiringCache + + +class FakeClock: + def __init__(self): + self.now = 1000.0 + + def __call__(self): + return self.now + + def advance(self, seconds): + self.now += seconds + + +def make_cache(max_len=10, max_age=5.0): + clock = FakeClock() + return ExpiringCache(max_len, max_age, clock=clock), clock + + +@pytest.mark.parametrize("max_len", [0, -1]) +def test_rejects_max_len_below_one(max_len): + with pytest.raises(ValueError): + ExpiringCache(max_len, 5.0) + + +def test_get_returns_stored_value(): + cache, _ = make_cache() + cache["a"] = 1 + assert cache.get("a") == 1 + + +def test_get_returns_none_for_missing_key(): + cache, _ = make_cache() + assert cache.get("missing") is None + + +def test_get_returns_default_for_missing_key(): + cache, _ = make_cache() + sentinel = object() + assert cache.get("missing", sentinel) is sentinel + + +def test_get_returns_falsy_values(): + cache, _ = make_cache() + cache["empty"] = [] + cache["zero"] = 0 + assert cache.get("empty", "default") == [] + assert cache.get("zero", "default") == 0 + + +def test_value_is_available_until_max_age(): + cache, clock = make_cache(max_age=5.0) + cache["a"] = 1 + clock.advance(4.999) + assert cache.get("a") == 1 + + +def test_value_expires_at_max_age(): + cache, clock = make_cache(max_age=5.0) + cache["a"] = 1 + clock.advance(5.0) + assert cache.get("a") is None + + +def test_get_of_expired_value_returns_default(): + cache, clock = make_cache(max_age=5.0) + cache["a"] = 1 + clock.advance(10) + assert cache.get("a", "default") == "default" + + +def test_get_removes_expired_entry(): + cache, clock = make_cache(max_age=5.0) + cache["a"] = 1 + clock.advance(5.0) + assert len(cache) == 1 + cache.get("a") + assert len(cache) == 0 + + +def test_get_does_not_extend_lifetime(): + cache, clock = make_cache(max_age=5.0) + cache["a"] = 1 + clock.advance(3) + assert cache.get("a") == 1 + clock.advance(2) + assert cache.get("a") is None + + +def test_rewrite_extends_lifetime(): + cache, clock = make_cache(max_age=5.0) + cache["a"] = 1 + clock.advance(3) + cache["a"] = 2 + clock.advance(3) + assert cache.get("a") == 2 + + +def test_zero_max_age_expires_immediately(): + cache, _ = make_cache(max_age=0) + cache["a"] = 1 + assert cache.get("a") is None + + +def test_entries_expire_independently(): + cache, clock = make_cache(max_age=5.0) + cache["a"] = 1 + clock.advance(3) + cache["b"] = 2 + clock.advance(2) + assert cache.get("a") is None + assert cache.get("b") == 2 + + +def test_retains_entries_up_to_max_len(): + cache, _ = make_cache(max_len=3) + for key in ("a", "b", "c"): + cache[key] = key + assert len(cache) == 3 + for key in ("a", "b", "c"): + assert cache.get(key) == key + + +def test_evicts_first_written_entry_when_full(): + cache, _ = make_cache(max_len=2) + cache["a"] = 1 + cache["b"] = 2 + cache["c"] = 3 + assert len(cache) == 2 + assert cache.get("a") is None + assert cache.get("b") == 2 + assert cache.get("c") == 3 + + +def test_read_does_not_protect_entry_from_eviction(): + cache, _ = make_cache(max_len=2) + cache["a"] = 1 + cache["b"] = 2 + assert cache.get("a") == 1 + cache["c"] = 3 + assert cache.get("a") is None + assert cache.get("b") == 2 + + +def test_replacing_key_when_full_does_not_evict_other_entries(): + cache, _ = make_cache(max_len=2) + cache["a"] = 1 + cache["b"] = 2 + cache["a"] = 10 + assert len(cache) == 2 + assert cache.get("a") == 10 + assert cache.get("b") == 2 + + +def test_replaced_key_becomes_newest_for_eviction(): + cache, _ = make_cache(max_len=2) + cache["a"] = 1 + cache["b"] = 2 + cache["a"] = 10 + cache["c"] = 3 + assert cache.get("b") is None + assert cache.get("a") == 10 + assert cache.get("c") == 3 + + +def test_max_len_of_one(): + cache, _ = make_cache(max_len=1) + cache["a"] = 1 + cache["b"] = 2 + assert len(cache) == 1 + assert cache.get("a") is None + assert cache.get("b") == 2 + + +def test_expired_entries_count_toward_max_len(): + cache, clock = make_cache(max_len=2, max_age=5.0) + cache["a"] = 1 + cache["b"] = 2 + clock.advance(10) + cache["c"] = 3 + assert len(cache) == 2 + assert cache.get("c") == 3 + + +def test_pop_removes_and_returns_value(): + cache, _ = make_cache() + cache["a"] = 1 + assert cache.pop("a") == 1 + assert cache.get("a") is None + assert len(cache) == 0 + + +def test_pop_of_missing_key_returns_default(): + cache, _ = make_cache() + assert cache.pop("missing") is None + assert cache.pop("missing", "default") == "default" + + +def test_pop_of_expired_key_returns_default_and_removes_entry(): + cache, clock = make_cache(max_age=5.0) + cache["a"] = 1 + clock.advance(5.0) + assert cache.pop("a", "default") == "default" + assert len(cache) == 0 + + +def test_pop_frees_space_for_new_key(): + cache, _ = make_cache(max_len=2) + cache["a"] = 1 + cache["b"] = 2 + cache.pop("a") + cache["c"] = 3 + assert cache.get("b") == 2 + assert cache.get("c") == 3 + + +def test_clear_removes_all_entries(): + cache, _ = make_cache() + cache["a"] = 1 + cache["b"] = 2 + cache.clear() + assert len(cache) == 0 + assert cache.get("a") is None + assert cache.get("b") is None + + +def test_cache_is_usable_after_clear(): + cache, _ = make_cache() + cache["a"] = 1 + cache.clear() + cache["a"] = 2 + assert cache.get("a") == 2 + + +def test_len_of_empty_cache_is_zero(): + cache, _ = make_cache() + assert len(cache) == 0 + + +def test_keys_can_be_tuples(): + cache, _ = make_cache() + cache[("features", "flag")] = 1 + assert cache.get(("features", "flag")) == 1 + + +def test_concurrent_writes_never_exceed_max_len(): + max_len = 50 + cache = ExpiringCache(max_len, 60) + thread_count = 8 + writes_per_thread = 2000 + barrier = threading.Barrier(thread_count) + errors = [] + + def writer(thread_id): + try: + barrier.wait() + for i in range(writes_per_thread): + cache[(thread_id, i % (max_len * 2))] = i + assert len(cache) <= max_len + except Exception as e: # pragma: no cover + errors.append(e) + + threads = [threading.Thread(target=writer, args=(t,)) for t in range(thread_count)] + for t in threads: + t.start() + for t in threads: + t.join() + + assert errors == [] + assert len(cache) == max_len + + +def test_concurrent_reads_writes_and_pops_do_not_raise(): + cache = ExpiringCache(20, 0.001) + thread_count = 8 + operations_per_thread = 2000 + barrier = threading.Barrier(thread_count) + errors = [] + + def worker(thread_id): + try: + barrier.wait() + for i in range(operations_per_thread): + key = i % 40 + operation = (thread_id + i) % 4 + if operation == 0: + cache[key] = i + elif operation == 1: + cache.get(key) + elif operation == 2: + cache.pop(key) + else: + len(cache) + if i % 500 == 0: + time.sleep(0.001) + except Exception as e: # pragma: no cover + errors.append(e) + + threads = [threading.Thread(target=worker, args=(t,)) for t in range(thread_count)] + for t in threads: + t.start() + for t in threads: + t.join() + + assert errors == [] + assert len(cache) <= 20 diff --git a/pyproject.toml b/pyproject.toml index f0d6d25c..dd3e847d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,7 +23,6 @@ classifiers = [ ] dependencies = [ "certifi>=2026.10.19", - "expiringdict>=1.1.4", "pyRFC3339>=1.0", "semver>=2.10.2", "urllib3>=1.26.0,<3", @@ -72,7 +71,6 @@ contract-tests = [ docs = [ "sphinx>=6,<9", "sphinx-rtd-theme>=1.3,<4.0", - "expiringdict>=1.1.4", "pyrfc3339>=1.0", "jsonpickle>1.4.1", "semver>=2.7.9",