Skip to content

Commit f9c76ee

Browse files
authored
feat: Add async persistent feature store foundation and Redis adapter (#488)
1 parent bd7be90 commit f9c76ee

8 files changed

Lines changed: 1055 additions & 85 deletions

File tree

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
"""
2+
This submodule contains support code for writing async feature store implementations.
3+
"""
4+
5+
import inspect
6+
from typing import Any, Dict, Mapping, Optional
7+
8+
from ldclient.feature_store import CacheConfig
9+
from ldclient.feature_store_helpers import (
10+
_CachingStoreWrapperBase,
11+
_ensure_encoded
12+
)
13+
from ldclient.interfaces import (
14+
AsyncFeatureStore,
15+
AsyncFeatureStoreCore,
16+
DiagnosticDescription
17+
)
18+
from ldclient.versioned_data_kind import VersionedDataKind
19+
20+
21+
class AsyncCachingStoreWrapper(_CachingStoreWrapperBase, DiagnosticDescription, AsyncFeatureStore):
22+
"""A partial implementation of :class:`ldclient.interfaces.AsyncFeatureStore`.
23+
24+
This class delegates the database-specific work to an implementation of
25+
:class:`ldclient.interfaces.AsyncFeatureStoreCore`, while adding optional caching behavior and
26+
other logic that would otherwise be repeated in every async feature store implementation. This
27+
makes it easier to create new async database integrations by implementing only the
28+
database-specific logic.
29+
30+
.. caution::
31+
This feature is experimental and should NOT be considered ready for production
32+
use. It may change or be removed without notice and is not subject to backwards
33+
compatibility guarantees. Pin to a specific minor version and review the changelog
34+
before upgrading.
35+
36+
The cache is a plain in-memory dict, which is safe for concurrent access within a single asyncio
37+
event loop because its reads and writes never suspend between one another.
38+
"""
39+
40+
_core: AsyncFeatureStoreCore
41+
42+
def __init__(self, core: AsyncFeatureStoreCore, cache_config: CacheConfig):
43+
"""Constructs an instance by wrapping a core implementation object.
44+
45+
:param core: the implementation object
46+
:param cache_config: the caching parameters
47+
"""
48+
self._core = core
49+
self._has_available_method = callable(getattr(core, 'is_available', None))
50+
super().__init__(cache_config)
51+
52+
async def is_available(self) -> bool:
53+
"""Tests whether the underlying store seems to be reachable.
54+
55+
Returns False if the core does not provide an availability check.
56+
"""
57+
# We know is_available exists since we are checking _has_available_method.
58+
return await self._core.is_available() if self._has_available_method else False # type: ignore
59+
60+
async def init(self, all_data: Mapping[VersionedDataKind, Mapping[str, dict]]) -> None:
61+
""" """
62+
await self._core.init_internal(all_data)
63+
self._cache_init(all_data)
64+
self._inited = True
65+
66+
async def get(self, kind: VersionedDataKind, key: str) -> Optional[Any]:
67+
""" """
68+
hit, value = self._cache_get_item(kind, key)
69+
if hit:
70+
return value
71+
encoded_item = await self._core.get_internal(kind, key)
72+
return self._cache_put_item(kind, key, encoded_item)
73+
74+
async def all(self, kind: VersionedDataKind) -> Dict[str, Any]:
75+
""" """
76+
hit, value = self._cache_get_all(kind)
77+
if hit:
78+
return value
79+
encoded_items = await self._core.get_all_internal(kind)
80+
return self._cache_put_all(kind, encoded_items)
81+
82+
async def delete(self, kind: VersionedDataKind, key: str, version: int) -> bool:
83+
""" """
84+
deleted_item = {"key": key, "version": version, "deleted": True}
85+
return await self.upsert(kind, deleted_item)
86+
87+
async def upsert(self, kind: VersionedDataKind, item: dict) -> bool:
88+
""" """
89+
encoded_item = _ensure_encoded(kind, item)
90+
new_state = await self._core.upsert_internal(kind, encoded_item)
91+
self._cache_put_upsert(kind, new_state)
92+
# The core returns the item we passed in if the write was applied, or the existing item if it
93+
# was rejected by the version check. Identity therefore tells us whether the store changed.
94+
return new_state is encoded_item
95+
96+
@property
97+
def initialized(self) -> bool:
98+
"""Returns whether ``init`` has completed in this process.
99+
100+
This property does not query the store: it is synchronous, but a persistent-store query is
101+
a coroutine, so it reflects only whether this process has initialized the store.
102+
"""
103+
return self._inited
104+
105+
async def close(self) -> None:
106+
"""Releases the cache and closes the underlying core if it supports it."""
107+
self.disable_cache()
108+
core_close = getattr(self._core, "close", None)
109+
if callable(core_close):
110+
result = core_close()
111+
if inspect.isawaitable(result):
112+
await result
113+
114+
def describe_configuration(self, config) -> str:
115+
describe = getattr(self._core, 'describe_configuration', None)
116+
if callable(describe):
117+
return describe(config)
118+
return "custom"

0 commit comments

Comments
 (0)