Repository navigation
feat(cache): add core cache module #265
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Draft
cassiofariasmachado
wants to merge
4
commits into
main
Choose a base branch
from
feature/add-cache-core-module
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Draft
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
1ab73d0
feat(cache): add core module for caching
cassiofariasmachado be75f1e
feat(cache): enhance caching module with backend type hinting and mul…
cassiofariasmachado 0d1dd66
feat(cache): add PROVIDER isolation and wire CSRF caching into core H…
cassiofariasmachado 449edab
chore: update uv.lock
cassiofariasmachado File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,53 @@ | ||
| """SAP Cloud SDK for Python - Cache module. | ||
|
|
||
| Provides a domain-agnostic, pluggable cache layer shared across all SDK | ||
| modules. Supports tenant and tenant-user isolation, configurable TTL and | ||
| expiry buffers, LRU eviction, and custom backends for multi-instance | ||
| deployments. | ||
|
|
||
| Global configuration example:: | ||
|
|
||
| from sap_cloud_sdk.cache import CacheConfig, configure_cache | ||
|
|
||
| configure_cache(CacheConfig( | ||
| default_ttl_seconds=600, | ||
| expiry_buffer_seconds=60, | ||
| max_size=2000, | ||
| )) | ||
|
|
||
| Disabling the cache for a specific client:: | ||
|
|
||
| from sap_cloud_sdk.destination import create_client | ||
| from sap_cloud_sdk.cache import CacheConfig | ||
|
|
||
| client = create_client(cache_config=CacheConfig(enabled=False)) | ||
|
|
||
| Custom backend example (Redis):: | ||
|
|
||
| from sap_cloud_sdk.cache import CacheBackend, CacheConfig, configure_cache | ||
|
|
||
| class RedisCacheBackend(CacheBackend): | ||
| def get(self, key): ... | ||
| def set(self, key, value, ttl_seconds): ... | ||
| def delete(self, key): ... | ||
| def clear(self): ... | ||
|
|
||
| configure_cache(CacheConfig(backend=RedisCacheBackend(...))) | ||
| """ | ||
|
|
||
| from sap_cloud_sdk.cache._backend import CacheBackend | ||
| from sap_cloud_sdk.cache._config import CacheConfig, configure_cache, get_cache_config | ||
| from sap_cloud_sdk.cache._isolation import IsolationStrategy | ||
| from sap_cloud_sdk.cache._lru_backend import InMemoryLRUBackend | ||
| from sap_cloud_sdk.cache.exceptions import BackendError, CacheError | ||
|
|
||
| __all__ = [ | ||
| "CacheBackend", | ||
| "CacheConfig", | ||
| "configure_cache", | ||
| "get_cache_config", | ||
| "IsolationStrategy", | ||
| "InMemoryLRUBackend", | ||
| "CacheError", | ||
| "BackendError", | ||
| ] |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,35 @@ | ||
| """Abstract cache backend interface.""" | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| from abc import ABC, abstractmethod | ||
| from typing import Any | ||
|
|
||
|
|
||
| class CacheBackend(ABC): | ||
| """Domain-agnostic key/value cache backend. | ||
|
|
||
| The backend has no awareness of tenants, TTL policy, namespaces, or domain | ||
| types. All of that is handled by the :class:`~sap_cloud_sdk.cache._cache.Cache` | ||
| façade before keys and values reach the backend. | ||
|
|
||
| Implement this to plug in any shared cache (Redis, Memcached, etc.) for | ||
| multi-instance deployments (Kyma ``replicas > 1``, Cloud Foundry | ||
| ``instances > 1``). | ||
| """ | ||
|
|
||
| @abstractmethod | ||
| def get(self, key: str) -> Any | None: | ||
| """Return the value for *key*, or ``None`` if absent or expired.""" | ||
|
|
||
| @abstractmethod | ||
| def set(self, key: str, value: Any, ttl_seconds: int) -> None: | ||
| """Store *value* under *key* with a time-to-live in seconds.""" | ||
|
|
||
| @abstractmethod | ||
| def delete(self, key: str) -> None: | ||
| """Remove the entry for *key* (no-op if absent).""" | ||
|
|
||
| @abstractmethod | ||
| def clear(self) -> None: | ||
| """Remove all entries.""" |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,148 @@ | ||
| """SDK-internal cache façade. | ||
|
|
||
| SDK modules instantiate :class:`Cache` per-client and call its | ||
| ``get``/``set``/``evict``/``reset`` methods. The façade handles: | ||
|
|
||
| - Selecting the active backend (per-client override or global default). | ||
| - Building namespaced, isolation-scoped keys. | ||
| - Applying the expiry buffer before forwarding TTLs to the backend. | ||
| - Honouring the ``enabled`` flag. | ||
|
|
||
| This class is **not part of the public API** and is not exported from | ||
| ``sap_cloud_sdk.cache``. Import it directly:: | ||
|
|
||
| from sap_cloud_sdk.cache._cache import Cache | ||
| """ | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| import logging | ||
| from typing import Any | ||
|
|
||
| from sap_cloud_sdk.cache._backend import CacheBackend | ||
| from sap_cloud_sdk.cache._config import CacheConfig, get_cache_config | ||
| from sap_cloud_sdk.cache._isolation import build_isolation_key | ||
| from sap_cloud_sdk.cache._lru_backend import InMemoryLRUBackend | ||
| from sap_cloud_sdk.cache.exceptions import BackendError | ||
|
|
||
| logger = logging.getLogger(__name__) | ||
|
|
||
|
|
||
| class Cache: | ||
| """Per-client cache façade. | ||
|
|
||
| Tenant and user isolation keys are derived automatically from the active | ||
| :func:`~sap_cloud_sdk.core.runtime_context.get_context` — callers do not | ||
| need to pass identity information explicitly. | ||
|
|
||
| Args: | ||
| config: Per-client override. When ``None``, the global config set via | ||
| :func:`~sap_cloud_sdk.cache._config.configure_cache` is used. | ||
| The config is snapshotted at construction time — subsequent calls | ||
| to :func:`configure_cache` do not affect an existing ``Cache``. | ||
| """ | ||
|
|
||
| def __init__(self, config: CacheConfig | None = None) -> None: | ||
| self._config: CacheConfig = config if config is not None else get_cache_config() | ||
| self._backend = self._resolve_backend() | ||
|
|
||
| # ------------------------------------------------------------------ | ||
| # Public façade methods | ||
| # ------------------------------------------------------------------ | ||
|
|
||
| def get(self, namespace: str, key: str) -> Any | None: | ||
| """Return a cached value, or ``None`` on miss or when disabled. | ||
|
|
||
| Args: | ||
| namespace: Domain namespace, e.g. ``"csrf"``. | ||
| key: Domain-specific key, e.g. the service root URL. | ||
| """ | ||
| if not self._config.enabled: | ||
| return None | ||
|
|
||
| full_key = self._make_full_key(namespace, key) | ||
| try: | ||
| return self._backend.get(full_key) | ||
| except Exception as e: | ||
| logger.warning("cache backend get() raised an exception: %s", e) | ||
| return None | ||
|
|
||
| def set( | ||
| self, | ||
| namespace: str, | ||
| key: str, | ||
| value: Any, | ||
| ttl_seconds: int | None, | ||
| ) -> None: | ||
| """Store a value in the cache. | ||
|
|
||
| The effective TTL forwarded to the backend is: | ||
| - *ttl_seconds* − ``expiry_buffer_seconds`` when *ttl_seconds* is given. | ||
| - ``default_ttl_seconds`` − ``expiry_buffer_seconds`` as fallback. | ||
|
|
||
| The result is clamped to a minimum of 1 second. | ||
|
|
||
| Args: | ||
| namespace: Domain namespace. | ||
| key: Domain-specific key. | ||
| value: Value to cache (must be serialisable by the backend). | ||
| ttl_seconds: Natural TTL derived from the resource (e.g. token | ||
| ``exp`` minus now). Pass ``None`` to use the configured | ||
| default. | ||
| """ | ||
| if not self._config.enabled: | ||
| return | ||
|
|
||
| raw_ttl = ( | ||
| ttl_seconds if ttl_seconds is not None else self._config.default_ttl_seconds | ||
| ) | ||
| effective_ttl = max(raw_ttl - self._config.expiry_buffer_seconds, 1) | ||
|
|
||
| full_key = self._make_full_key(namespace, key) | ||
| try: | ||
| self._backend.set(full_key, value, effective_ttl) | ||
| except Exception as e: | ||
| raise BackendError(f"cache backend set() failed: {e}") from e | ||
|
|
||
| def evict(self, namespace: str, key: str) -> None: | ||
| """Remove a single entry (no-op if absent or cache is disabled). | ||
|
|
||
| Args: | ||
| namespace: Domain namespace. | ||
| key: Domain-specific key. | ||
| """ | ||
| if not self._config.enabled: | ||
| return | ||
|
|
||
| full_key = self._make_full_key(namespace, key) | ||
| try: | ||
| self._backend.delete(full_key) | ||
| except Exception as e: | ||
| logger.warning("cache backend delete() raised an exception: %s", e) | ||
|
|
||
| def reset(self) -> None: | ||
| """Clear all entries from the backend. | ||
|
|
||
| Use with care in production — this forces a full re-fetch of every | ||
| cached resource on the next access. | ||
| """ | ||
| try: | ||
| self._backend.clear() | ||
| except Exception as e: | ||
| logger.warning("cache backend clear() raised an exception: %s", e) | ||
|
|
||
| # ------------------------------------------------------------------ | ||
| # Private helpers | ||
| # ------------------------------------------------------------------ | ||
|
|
||
| def _resolve_backend(self) -> CacheBackend: | ||
| if self._config.backend is not None: | ||
| return self._config.backend | ||
| return InMemoryLRUBackend( | ||
| max_size=self._config.max_size, | ||
| on_evict=self._config.on_evict, | ||
| ) | ||
|
|
||
| def _make_full_key(self, namespace: str, key: str) -> str: | ||
| isolation_key = build_isolation_key(strategy=self._config.isolation_strategy) | ||
| return f"{namespace}::{isolation_key}::{key}" |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,67 @@ | ||
| """Global cache configuration and registry.""" | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| from dataclasses import dataclass, field | ||
| from typing import TYPE_CHECKING, Callable | ||
|
|
||
| if TYPE_CHECKING: | ||
| from sap_cloud_sdk.cache._backend import CacheBackend | ||
| from sap_cloud_sdk.cache._isolation import IsolationStrategy | ||
|
|
||
|
|
||
| @dataclass | ||
| class CacheConfig: | ||
| """Configuration for the SDK cache layer. | ||
|
|
||
| Can be set globally via :func:`configure_cache` or passed per-client | ||
| to override the global for that client only. | ||
|
|
||
| Attributes: | ||
| enabled: Master on/off switch. When ``False``, all gets return | ||
| ``None`` and all sets are no-ops. | ||
| isolation_strategy: Override the automatic isolation selection. | ||
| ``None`` means auto-detect from context (``TENANT_USER`` when a | ||
| user ID is present, ``TENANT`` otherwise). | ||
| default_ttl_seconds: Fallback TTL used when the caller does not | ||
| supply a natural TTL (e.g. from a token ``exp`` claim). | ||
| expiry_buffer_seconds: Seconds subtracted from any derived TTL to | ||
| pre-invalidate entries before they expire on the remote service. | ||
| max_size: Maximum number of entries in the built-in in-memory | ||
| backend before LRU eviction kicks in. | ||
| backend: Custom cache backend. ``None`` uses the built-in | ||
| :class:`~sap_cloud_sdk.cache._lru_backend.InMemoryLRUBackend`. | ||
| on_evict: Optional callback invoked when an entry is evicted. | ||
| Signature: ``(key: str, reason: str) -> None`` where *reason* | ||
| is one of ``"ttl"``, ``"lru"``, or ``"manual"``. | ||
| """ | ||
|
|
||
| enabled: bool = True | ||
| isolation_strategy: IsolationStrategy | None = None | ||
| default_ttl_seconds: int = 300 | ||
| expiry_buffer_seconds: int = 30 | ||
| max_size: int = 1000 | ||
| backend: CacheBackend | None = None | ||
| on_evict: Callable[[str, str], None] | None = field(default=None, repr=False) | ||
|
|
||
|
|
||
| _global_config: CacheConfig = CacheConfig() | ||
|
|
||
|
|
||
| def configure_cache(config: CacheConfig) -> None: | ||
| """Set the global cache configuration. | ||
|
|
||
| Must be called before any SDK client is created. Hot-reload is not | ||
| supported — changes after clients are constructed have no effect on | ||
| already-instantiated :class:`~sap_cloud_sdk.cache._cache.Cache` objects. | ||
|
|
||
| Args: | ||
| config: The new global configuration. | ||
| """ | ||
| global _global_config | ||
| _global_config = config | ||
|
|
||
|
|
||
| def get_cache_config() -> CacheConfig: | ||
| """Return the current global cache configuration.""" | ||
| return _global_config |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,78 @@ | ||
| """Cache isolation strategy and key construction.""" | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| import hashlib | ||
| import logging | ||
| from enum import Enum | ||
|
|
||
| from sap_cloud_sdk.core.runtime_context import APP_TENANT_ID, USER_ID, get_context | ||
|
|
||
| logger = logging.getLogger(__name__) | ||
|
|
||
|
|
||
| class IsolationStrategy(str, Enum): | ||
| """Controls how cache keys are scoped to tenants and users. | ||
|
|
||
| ``TENANT`` | ||
| One namespace per tenant. Suitable for data that is shared across all | ||
| users of a tenant (e.g. configuration, service metadata). | ||
|
|
||
| ``TENANT_USER`` | ||
| One namespace per (tenant, user) pair. Prevents cross-user cache hits | ||
| within the same tenant. Use for user-specific data. | ||
|
|
||
| ``PROVIDER`` | ||
| A single shared namespace for the whole service, ignoring tenant and | ||
| user context entirely. Use for service-wide data such as CSRF tokens | ||
| or provider-level metadata that is not tenant-specific. | ||
| """ | ||
|
|
||
| TENANT = "tenant" | ||
| TENANT_USER = "tenant_user" | ||
| PROVIDER = "provider" | ||
|
|
||
|
|
||
| def build_isolation_key(strategy: IsolationStrategy | None = None) -> str: | ||
| """Derive a cache isolation key from the current runtime context. | ||
|
|
||
| Reads ``tenant_id`` and ``user_id`` implicitly from the active | ||
| :func:`~sap_cloud_sdk.core.runtime_context.get_context`. | ||
|
|
||
| When *strategy* is ``None``, the strategy is selected automatically: | ||
| ``TENANT_USER`` if a user ID is present in context, ``TENANT`` otherwise. | ||
|
|
||
| Downgrading explicitly from ``TENANT_USER`` to ``TENANT`` when a user ID | ||
| is available risks cross-user contamination and triggers a warning. | ||
|
|
||
| Args: | ||
| strategy: Explicit override. ``None`` means auto-detect. | ||
|
|
||
| Returns: | ||
| An opaque string suitable for inclusion in a cache key. | ||
| """ | ||
| if strategy is IsolationStrategy.PROVIDER: | ||
| return "__provider__" | ||
|
|
||
| ctx = get_context() | ||
| tenant_id = ctx.get(APP_TENANT_ID) | ||
| user_id = ctx.get(USER_ID) | ||
|
|
||
| has_user = bool(user_id) | ||
| effective = strategy | ||
|
|
||
| if effective is None: | ||
| effective = ( | ||
| IsolationStrategy.TENANT_USER if has_user else IsolationStrategy.TENANT | ||
| ) | ||
| elif effective is IsolationStrategy.TENANT and has_user: | ||
| logger.warning( | ||
| "cache isolation downgraded from TENANT_USER to TENANT while user_id is " | ||
| "present — this may cause cross-user cache contamination" | ||
| ) | ||
|
|
||
| if effective is IsolationStrategy.TENANT_USER and has_user: | ||
| material = f"{tenant_id}|{user_id}" | ||
| return hashlib.sha256(material.encode()).hexdigest()[:32] | ||
|
|
||
| return tenant_id or "" | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is this mandatory? If yes, we would need a provider level.