Skip to content

Commit 5bbcd43

Browse files
feat(cache): add optional module= keyword for per-module shared backend
1 parent 2737a2f commit 5bbcd43

5 files changed

Lines changed: 227 additions & 2 deletions

File tree

‎src/sap_cloud_sdk/cache/_cache.py‎

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
from sap_cloud_sdk.cache._config import CacheConfig, get_cache_config
2424
from sap_cloud_sdk.cache._isolation import build_isolation_key
2525
from sap_cloud_sdk.cache._lru_backend import InMemoryLRUBackend
26+
from sap_cloud_sdk.cache._registry import get_module_backend
2627
from sap_cloud_sdk.cache.exceptions import BackendError
2728

2829
logger = logging.getLogger(__name__)
@@ -40,10 +41,31 @@ class Cache:
4041
:func:`~sap_cloud_sdk.cache._config.configure_cache` is used.
4142
The config is snapshotted at construction time — subsequent calls
4243
to :func:`configure_cache` do not affect an existing ``Cache``.
44+
module: Optional logical module name (e.g. ``"agentgateway"``).
45+
When given, all ``Cache`` instances with the same *module* name
46+
share a single :class:`~sap_cloud_sdk.cache._lru_backend.InMemoryLRUBackend`
47+
that lives for the process lifetime. This lets modules that
48+
create a new client on every request still benefit from
49+
cross-request caching.
50+
51+
``max_size`` and ``on_evict`` are taken from the **first**
52+
``Cache`` created for a given *module* name; later callers with
53+
different values are silently ignored. Pass ``None`` (the
54+
default) to get a per-instance cache backend — nothing is
55+
shared.
56+
57+
An explicit ``CacheConfig(backend=...)`` always takes precedence
58+
over *module*.
4359
"""
4460

45-
def __init__(self, config: CacheConfig | None = None) -> None:
61+
def __init__(
62+
self,
63+
config: CacheConfig | None = None,
64+
*,
65+
module: str | None = None,
66+
) -> None:
4667
self._config: CacheConfig = config if config is not None else get_cache_config()
68+
self._module: str | None = module
4769
self._backend = self._resolve_backend()
4870

4971
# ------------------------------------------------------------------
@@ -136,8 +158,19 @@ def reset(self) -> None:
136158
# ------------------------------------------------------------------
137159

138160
def _resolve_backend(self) -> CacheBackend:
161+
# 1. Explicit backend wins (e.g. Redis)
139162
if self._config.backend is not None:
140163
return self._config.backend
164+
165+
# 2. Per-module shared backend, memoized by name
166+
if self._module is not None:
167+
return get_module_backend(
168+
self._module,
169+
self._config.max_size,
170+
self._config.on_evict,
171+
)
172+
173+
# 3. Fresh per-instance backend
141174
return InMemoryLRUBackend(
142175
max_size=self._config.max_size,
143176
on_evict=self._config.on_evict,
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
"""Per-module shared backend registry.
2+
3+
SDK modules that create a new client on every request can opt into a
4+
per-module *shared* backend so that the cache survives across requests.
5+
This module holds the registry that maps module names to their backends.
6+
7+
Sizing (``max_size``, ``on_evict``) is fixed on the **first** call for a
8+
given module name; subsequent calls with different values are ignored.
9+
That is intentional — a module uses one sizing policy and registers its
10+
backend exactly once at startup.
11+
12+
Private to the cache package; not exported from ``sap_cloud_sdk.cache``.
13+
"""
14+
15+
from __future__ import annotations
16+
17+
import threading
18+
from typing import Callable
19+
20+
from sap_cloud_sdk.cache._lru_backend import InMemoryLRUBackend
21+
22+
# Module-level registry guarded by a lock so concurrent first-time
23+
# registrations are safe.
24+
_lock: threading.Lock = threading.Lock()
25+
_backends: dict[str, InMemoryLRUBackend] = {}
26+
27+
28+
def get_module_backend(
29+
name: str,
30+
max_size: int,
31+
on_evict: Callable[[str, str], None] | None,
32+
) -> InMemoryLRUBackend:
33+
"""Return the shared :class:`~sap_cloud_sdk.cache._lru_backend.InMemoryLRUBackend`
34+
for *name*, creating it on the first call.
35+
36+
Args:
37+
name: Logical module identifier, e.g. ``"agentgateway"``.
38+
max_size: Maximum backend capacity (LRU). Used only on the first
39+
call for *name*; ignored on subsequent calls.
40+
on_evict: Eviction callback. Used only on the first call for *name*.
41+
42+
Returns:
43+
The singleton :class:`InMemoryLRUBackend` for *name*.
44+
"""
45+
# Fast path — backend already registered.
46+
try:
47+
return _backends[name]
48+
except KeyError:
49+
pass
50+
51+
with _lock:
52+
# Re-check under the lock to avoid a race between two first-time callers.
53+
if name not in _backends:
54+
_backends[name] = InMemoryLRUBackend(max_size=max_size, on_evict=on_evict)
55+
56+
return _backends[name]
57+
58+
59+
def reset_module_backends() -> None:
60+
"""Clear the registry. **Test seam only — do not call in production.**
61+
62+
Removes all per-module backend singletons so that the next call to
63+
:func:`get_module_backend` starts fresh. This is necessary because
64+
module-level state persists across test cases.
65+
"""
66+
with _lock:
67+
_backends.clear()

‎src/sap_cloud_sdk/cache/user-guide.md‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,29 @@ The backend interface has exactly four methods and no awareness of tenants, TTL
120120

121121
For a real, in-tree example of how the cache is used, see `XsuaaAuthProvider` in `sap_cloud_sdk.core.protocol.http.models`. It uses `IsolationStrategy.TENANT` to cache OAuth2 sessions per tenant, with TTL derived from the token's `expires_in` field and automatic eviction on secret rotation.
122122

123+
## Per-Module Shared Backend
124+
125+
By default each `Cache` instance gets its own `InMemoryLRUBackend`. That is fine when the same client object is reused across requests, but some modules (e.g. Agent Gateway) create a fresh client on every request. In that case the cache would be empty on each request, defeating its purpose.
126+
127+
Pass `module=` to share a single backend across all `Cache` instances that name the same module:
128+
129+
```python
130+
from sap_cloud_sdk.cache._cache import Cache
131+
from sap_cloud_sdk.cache import CacheConfig, IsolationStrategy
132+
133+
# Every time this module creates a new client, it gets the same backend.
134+
self._cache = Cache(
135+
CacheConfig(isolation_strategy=IsolationStrategy.TENANT),
136+
module="agentgateway",
137+
)
138+
```
139+
140+
The backend is created once (process-scoped, singleton per module name) and reused for the lifetime of the process. Tenant isolation still works normally — the isolation key is derived from the active `RuntimeContext` on every `get`/`set`, so entries are never shared across tenants.
141+
142+
### Sizing and eviction
143+
144+
`max_size` and `on_evict` are taken from the **first** `Cache` created for a given module name. Later callers with different values are silently ignored. This is intentional: a module uses one sizing policy for its backend.
145+
123146
## API Reference
124147

125148
### `configure_cache(config: CacheConfig) -> None`

‎src/sap_cloud_sdk/core/protocol/http/models.py‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,8 @@ def __init__(
8383
self._config = config_factory()
8484
self._timeout = timeout
8585
self._cache: Cache = Cache(
86-
CacheConfig(isolation_strategy=IsolationStrategy.TENANT)
86+
CacheConfig(isolation_strategy=IsolationStrategy.TENANT),
87+
module="core.protocol.http",
8788
)
8889

8990
def get_session(self, tenant_subdomain: Optional[str] = None) -> OAuth2Session:

‎tests/cache/unit/test_cache.py‎

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"""Unit tests for the Cache façade."""
22

3+
from collections.abc import Generator
34
from unittest.mock import MagicMock
45

56
import pytest
@@ -8,6 +9,7 @@
89
from sap_cloud_sdk.cache._cache import Cache
910
from sap_cloud_sdk.cache._config import CacheConfig, configure_cache
1011
from sap_cloud_sdk.cache._isolation import IsolationStrategy
12+
from sap_cloud_sdk.cache._registry import reset_module_backends
1113
from sap_cloud_sdk.core.runtime_context import APP_TENANT_ID, USER_ID, RuntimeContext
1214
from sap_cloud_sdk.core.runtime_context._context import sdk_context
1315
from sap_cloud_sdk.cache.exceptions import BackendError
@@ -22,6 +24,18 @@ def _ctx(tenant_id: str | None = None, user_id: str | None = None) -> RuntimeCon
2224
return RuntimeContext(values)
2325

2426

27+
@pytest.fixture(autouse=True)
28+
def _clear_module_registry() -> Generator[None, None, None]:
29+
"""Reset the per-module backend registry before every test.
30+
31+
Module-level singletons persist across test cases, which would cause
32+
tests that use ``module=`` to interfere with each other.
33+
"""
34+
reset_module_backends()
35+
yield
36+
reset_module_backends()
37+
38+
2539
class TestCacheGet:
2640
def test_get_miss_returns_none(self) -> None:
2741
cache = Cache(CacheConfig())
@@ -195,3 +209,90 @@ def test_full_key_includes_namespace_and_key(self) -> None:
195209
assert full_key.startswith("destination::")
196210
assert full_key.endswith("::my-dest")
197211
assert "tenant-xyz" in full_key
212+
213+
214+
class TestCacheModuleBackend:
215+
"""Tests for the optional ``module=`` keyword on :class:`Cache`."""
216+
217+
def test_same_module_name_shares_backend(self) -> None:
218+
"""Two Cache instances with the same module share state."""
219+
cfg = CacheConfig(isolation_strategy=IsolationStrategy.TENANT)
220+
cache_a = Cache(cfg, module="mymodule")
221+
cache_b = Cache(cfg, module="mymodule")
222+
223+
with sdk_context(_ctx(tenant_id="t1")):
224+
cache_a.set("ns", "key", "shared-value", ttl_seconds=60)
225+
assert cache_b.get("ns", "key") == "shared-value"
226+
227+
def test_different_module_names_are_isolated(self) -> None:
228+
"""Two Cache instances with different module names do not share state."""
229+
cfg = CacheConfig(isolation_strategy=IsolationStrategy.TENANT)
230+
cache_x = Cache(cfg, module="module-x")
231+
cache_y = Cache(cfg, module="module-y")
232+
233+
with sdk_context(_ctx(tenant_id="t1")):
234+
cache_x.set("ns", "key", "value-x", ttl_seconds=60)
235+
assert cache_y.get("ns", "key") is None
236+
237+
def test_no_module_gives_fresh_per_instance_backend(self) -> None:
238+
"""Without module=, each Cache instance has its own backend (legacy behavior)."""
239+
cfg = CacheConfig(isolation_strategy=IsolationStrategy.TENANT)
240+
cache_1 = Cache(cfg)
241+
cache_2 = Cache(cfg)
242+
243+
with sdk_context(_ctx(tenant_id="t1")):
244+
cache_1.set("ns", "key", "value-1", ttl_seconds=60)
245+
# cache_2 has a separate backend, so it must not see cache_1's entry.
246+
assert cache_2.get("ns", "key") is None
247+
248+
def test_explicit_backend_overrides_module(self) -> None:
249+
"""CacheConfig(backend=...) wins over module=."""
250+
explicit_backend = MagicMock(spec=CacheBackend)
251+
explicit_backend.get.return_value = "from-explicit"
252+
cache = Cache(CacheConfig(backend=explicit_backend), module="mymodule")
253+
254+
with sdk_context(_ctx(tenant_id="t1")):
255+
result = cache.get("ns", "key")
256+
257+
explicit_backend.get.assert_called_once()
258+
assert result == "from-explicit"
259+
260+
def test_module_backend_survives_across_simulated_requests(self) -> None:
261+
"""Simulate per-request client creation: backend must carry state across."""
262+
cfg = CacheConfig(isolation_strategy=IsolationStrategy.TENANT)
263+
264+
# First "request": create a client and populate the cache.
265+
cache_req1 = Cache(cfg, module="agentgateway")
266+
with sdk_context(_ctx(tenant_id="t1")):
267+
cache_req1.set("token", "svc-url", "my-token", ttl_seconds=60)
268+
269+
# Second "request": a brand-new client must still find the entry.
270+
cache_req2 = Cache(cfg, module="agentgateway")
271+
with sdk_context(_ctx(tenant_id="t1")):
272+
assert cache_req2.get("token", "svc-url") == "my-token"
273+
274+
def test_module_backend_tenant_isolation(self) -> None:
275+
"""Shared module backend must still isolate across tenants."""
276+
cfg = CacheConfig(isolation_strategy=IsolationStrategy.TENANT)
277+
cache = Cache(cfg, module="agentgateway")
278+
279+
with sdk_context(_ctx(tenant_id="tenant-a")):
280+
cache.set("token", "svc", "token-a", ttl_seconds=60)
281+
with sdk_context(_ctx(tenant_id="tenant-b")):
282+
assert cache.get("token", "svc") is None
283+
284+
def test_module_backend_sizing_from_first_caller(self) -> None:
285+
"""max_size is fixed on the first Cache created for a module."""
286+
# First caller: max_size=2
287+
cache_first = Cache(CacheConfig(max_size=2), module="sized-module")
288+
# Second caller: max_size=500 (ignored — backend already exists)
289+
cache_second = Cache(CacheConfig(max_size=500), module="sized-module")
290+
291+
# Both should share the same backend (capped at 2).
292+
with sdk_context(_ctx(tenant_id="t1")):
293+
cache_first.set("ns", "k1", "v1", ttl_seconds=60)
294+
cache_first.set("ns", "k2", "v2", ttl_seconds=60)
295+
# Third entry evicts the oldest (LRU) when max_size=2.
296+
cache_first.set("ns", "k3", "v3", ttl_seconds=60)
297+
# k3 is always present; exactly one of k1/k2 was evicted.
298+
assert cache_second.get("ns", "k3") == "v3"

0 commit comments

Comments
 (0)