Skip to content

Commit 8458e56

Browse files
feat(cache): add core module for caching
1 parent 5fc7b9f commit 8458e56

18 files changed

Lines changed: 1088 additions & 25 deletions

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ dependencies = [
3838
"opentelemetry-instrumentation-flask~=0.64b0",
3939
"mcp>=1.1.0",
4040
"cryptography>=46.0.3",
41+
"cachetools~=5.5.2",
4142
]
4243

4344
[project.optional-dependencies]
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
"""SAP Cloud SDK for Python - Cache module.
2+
3+
Provides a domain-agnostic, pluggable cache layer shared across all SDK
4+
modules. Supports tenant and tenant-user isolation, configurable TTL and
5+
expiry buffers, LRU eviction, and custom backends for multi-instance
6+
deployments.
7+
8+
Global configuration example::
9+
10+
from sap_cloud_sdk.cache import CacheConfig, configure_cache
11+
12+
configure_cache(CacheConfig(
13+
default_ttl_seconds=600,
14+
expiry_buffer_seconds=60,
15+
max_size=2000,
16+
))
17+
18+
Disabling the cache for a specific client::
19+
20+
from sap_cloud_sdk.destination import create_client
21+
from sap_cloud_sdk.cache import CacheConfig
22+
23+
client = create_client(cache_config=CacheConfig(enabled=False))
24+
25+
Custom backend example (Redis)::
26+
27+
from sap_cloud_sdk.cache import CacheBackend, CacheConfig, configure_cache
28+
29+
class RedisCacheBackend(CacheBackend):
30+
def get(self, key): ...
31+
def set(self, key, value, ttl_seconds): ...
32+
def delete(self, key): ...
33+
def clear(self): ...
34+
35+
configure_cache(CacheConfig(backend=RedisCacheBackend(...)))
36+
"""
37+
38+
from sap_cloud_sdk.cache._backend import CacheBackend
39+
from sap_cloud_sdk.cache._config import CacheConfig, configure_cache, get_cache_config
40+
from sap_cloud_sdk.cache._isolation import IsolationStrategy
41+
from sap_cloud_sdk.cache._lru_backend import InMemoryLRUBackend
42+
from sap_cloud_sdk.cache.exceptions import BackendError, CacheError
43+
44+
__all__ = [
45+
"CacheBackend",
46+
"CacheConfig",
47+
"configure_cache",
48+
"get_cache_config",
49+
"IsolationStrategy",
50+
"InMemoryLRUBackend",
51+
"CacheError",
52+
"BackendError",
53+
]
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
"""Abstract cache backend interface."""
2+
3+
from __future__ import annotations
4+
5+
from abc import ABC, abstractmethod
6+
from typing import Any
7+
8+
9+
class CacheBackend(ABC):
10+
"""Domain-agnostic key/value cache backend.
11+
12+
The backend has no awareness of tenants, TTL policy, namespaces, or domain
13+
types. All of that is handled by the :class:`~sap_cloud_sdk.cache._cache.Cache`
14+
façade before keys and values reach the backend.
15+
16+
Implement this to plug in any shared cache (Redis, Memcached, etc.) for
17+
multi-instance deployments (Kyma ``replicas > 1``, Cloud Foundry
18+
``instances > 1``).
19+
"""
20+
21+
@abstractmethod
22+
def get(self, key: str) -> Any | None:
23+
"""Return the value for *key*, or ``None`` if absent or expired."""
24+
25+
@abstractmethod
26+
def set(self, key: str, value: Any, ttl_seconds: int) -> None:
27+
"""Store *value* under *key* with a time-to-live in seconds."""
28+
29+
@abstractmethod
30+
def delete(self, key: str) -> None:
31+
"""Remove the entry for *key* (no-op if absent)."""
32+
33+
@abstractmethod
34+
def clear(self) -> None:
35+
"""Remove all entries."""

src/sap_cloud_sdk/cache/_cache.py

Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
"""SDK-internal cache façade.
2+
3+
SDK modules instantiate :class:`Cache` per-client and call its
4+
``get``/``set``/``evict``/``reset`` methods. The façade handles:
5+
6+
- Selecting the active backend (per-client override or global default).
7+
- Building namespaced, isolation-scoped keys.
8+
- Applying the expiry buffer before forwarding TTLs to the backend.
9+
- Honouring the ``enabled`` flag.
10+
11+
This class is **not part of the public API** and is not exported from
12+
``sap_cloud_sdk.cache``. Import it directly::
13+
14+
from sap_cloud_sdk.cache._cache import Cache
15+
"""
16+
17+
from __future__ import annotations
18+
19+
import logging
20+
from typing import Any
21+
22+
from sap_cloud_sdk.cache._config import CacheConfig, get_cache_config
23+
from sap_cloud_sdk.cache._isolation import build_isolation_key
24+
from sap_cloud_sdk.cache._lru_backend import InMemoryLRUBackend
25+
from sap_cloud_sdk.cache.exceptions import BackendError
26+
27+
logger = logging.getLogger(__name__)
28+
29+
30+
class Cache:
31+
"""Per-client cache façade.
32+
33+
Args:
34+
config: Per-client override. When ``None``, the global config set via
35+
:func:`~sap_cloud_sdk.cache._config.configure_cache` is used.
36+
The config is snapshotted at construction time — subsequent calls
37+
to :func:`configure_cache` do not affect an existing ``Cache``.
38+
"""
39+
40+
def __init__(self, config: CacheConfig | None = None) -> None:
41+
self._config: CacheConfig = config if config is not None else get_cache_config()
42+
self._backend = self._resolve_backend()
43+
44+
# ------------------------------------------------------------------
45+
# Public façade methods
46+
# ------------------------------------------------------------------
47+
48+
def get(
49+
self,
50+
namespace: str,
51+
key: str,
52+
tenant_id: str,
53+
user_id: str | None = None,
54+
) -> Any | None:
55+
"""Return a cached value, or ``None`` on miss or when disabled.
56+
57+
Args:
58+
namespace: Domain namespace, e.g. ``"destination"``.
59+
key: Domain-specific key, e.g. the destination name.
60+
tenant_id: Tenant identifier for isolation key derivation.
61+
user_id: Optional user identifier. Drives ``TENANT_USER``
62+
isolation when present (and no explicit strategy override).
63+
"""
64+
if not self._config.enabled:
65+
return None
66+
67+
full_key = self._make_full_key(namespace, key, tenant_id, user_id)
68+
try:
69+
return self._backend.get(full_key)
70+
except Exception as e:
71+
logger.warning("cache backend get() raised an exception: %s", e)
72+
return None
73+
74+
def set(
75+
self,
76+
namespace: str,
77+
key: str,
78+
value: Any,
79+
ttl_seconds: int | None,
80+
tenant_id: str,
81+
user_id: str | None = None,
82+
) -> None:
83+
"""Store a value in the cache.
84+
85+
The effective TTL forwarded to the backend is:
86+
- *ttl_seconds* − ``expiry_buffer_seconds`` when *ttl_seconds* is given.
87+
- ``default_ttl_seconds`` − ``expiry_buffer_seconds`` as fallback.
88+
89+
The result is clamped to a minimum of 1 second.
90+
91+
Args:
92+
namespace: Domain namespace.
93+
key: Domain-specific key.
94+
value: Value to cache (must be serialisable by the backend).
95+
ttl_seconds: Natural TTL derived from the resource (e.g. token
96+
``exp`` minus now). Pass ``None`` to use the configured
97+
default.
98+
tenant_id: Tenant identifier for isolation key derivation.
99+
user_id: Optional user identifier.
100+
"""
101+
if not self._config.enabled:
102+
return
103+
104+
raw_ttl = (
105+
ttl_seconds if ttl_seconds is not None else self._config.default_ttl_seconds
106+
)
107+
effective_ttl = max(raw_ttl - self._config.expiry_buffer_seconds, 1)
108+
109+
full_key = self._make_full_key(namespace, key, tenant_id, user_id)
110+
try:
111+
self._backend.set(full_key, value, effective_ttl)
112+
except Exception as e:
113+
raise BackendError(f"cache backend set() failed: {e}") from e
114+
115+
def evict(
116+
self,
117+
namespace: str,
118+
key: str,
119+
tenant_id: str,
120+
user_id: str | None = None,
121+
) -> None:
122+
"""Remove a single entry (no-op if absent or cache is disabled).
123+
124+
Args:
125+
namespace: Domain namespace.
126+
key: Domain-specific key.
127+
tenant_id: Tenant identifier.
128+
user_id: Optional user identifier.
129+
"""
130+
if not self._config.enabled:
131+
return
132+
133+
full_key = self._make_full_key(namespace, key, tenant_id, user_id)
134+
try:
135+
self._backend.delete(full_key)
136+
except Exception as e:
137+
logger.warning("cache backend delete() raised an exception: %s", e)
138+
139+
def reset(self) -> None:
140+
"""Clear all entries from the backend.
141+
142+
Use with care in production — this forces a full re-fetch of every
143+
cached resource on the next access.
144+
"""
145+
try:
146+
self._backend.clear()
147+
except Exception as e:
148+
logger.warning("cache backend clear() raised an exception: %s", e)
149+
150+
# ------------------------------------------------------------------
151+
# Private helpers
152+
# ------------------------------------------------------------------
153+
154+
def _resolve_backend(self) -> Any:
155+
if self._config.backend is not None:
156+
return self._config.backend
157+
return InMemoryLRUBackend(
158+
max_size=self._config.max_size,
159+
on_evict=self._config.on_evict,
160+
)
161+
162+
def _make_full_key(
163+
self,
164+
namespace: str,
165+
key: str,
166+
tenant_id: str,
167+
user_id: str | None,
168+
) -> str:
169+
isolation_key = build_isolation_key(
170+
tenant_id=tenant_id,
171+
user_id=user_id,
172+
strategy=self._config.isolation_strategy,
173+
)
174+
return f"{namespace}::{isolation_key}::{key}"

src/sap_cloud_sdk/cache/_config.py

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
"""Global cache configuration and registry."""
2+
3+
from __future__ import annotations
4+
5+
from dataclasses import dataclass, field
6+
from typing import TYPE_CHECKING, Callable
7+
8+
if TYPE_CHECKING:
9+
from sap_cloud_sdk.cache._backend import CacheBackend
10+
from sap_cloud_sdk.cache._isolation import IsolationStrategy
11+
12+
13+
@dataclass
14+
class CacheConfig:
15+
"""Configuration for the SDK cache layer.
16+
17+
Can be set globally via :func:`configure_cache` or passed per-client
18+
to override the global for that client only.
19+
20+
Attributes:
21+
enabled: Master on/off switch. When ``False``, all gets return
22+
``None`` and all sets are no-ops.
23+
isolation_strategy: Override the automatic isolation selection.
24+
``None`` means auto-detect from context (``TENANT_USER`` when a
25+
user ID is present, ``TENANT`` otherwise).
26+
default_ttl_seconds: Fallback TTL used when the caller does not
27+
supply a natural TTL (e.g. from a token ``exp`` claim).
28+
expiry_buffer_seconds: Seconds subtracted from any derived TTL to
29+
pre-invalidate entries before they expire on the remote service.
30+
max_size: Maximum number of entries in the built-in in-memory
31+
backend before LRU eviction kicks in.
32+
backend: Custom cache backend. ``None`` uses the built-in
33+
:class:`~sap_cloud_sdk.cache._lru_backend.InMemoryLRUBackend`.
34+
on_evict: Optional callback invoked when an entry is evicted.
35+
Signature: ``(key: str, reason: str) -> None`` where *reason*
36+
is one of ``"ttl"``, ``"lru"``, or ``"manual"``.
37+
"""
38+
39+
enabled: bool = True
40+
isolation_strategy: IsolationStrategy | None = None
41+
default_ttl_seconds: int = 300
42+
expiry_buffer_seconds: int = 30
43+
max_size: int = 1000
44+
backend: CacheBackend | None = None
45+
on_evict: Callable[[str, str], None] | None = field(default=None, repr=False)
46+
47+
48+
_global_config: CacheConfig = CacheConfig()
49+
50+
51+
def configure_cache(config: CacheConfig) -> None:
52+
"""Set the global cache configuration.
53+
54+
Must be called before any SDK client is created. Hot-reload is not
55+
supported — changes after clients are constructed have no effect on
56+
already-instantiated :class:`~sap_cloud_sdk.cache._cache.Cache` objects.
57+
58+
Args:
59+
config: The new global configuration.
60+
"""
61+
global _global_config
62+
_global_config = config
63+
64+
65+
def get_cache_config() -> CacheConfig:
66+
"""Return the current global cache configuration."""
67+
return _global_config

0 commit comments

Comments
 (0)