Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ dependencies = [
"opentelemetry-instrumentation-flask~=0.64b0",
"mcp>=1.1.0",
"cryptography>=46.0.3",
"cachetools~=5.5.2",
]

[project.optional-dependencies]
Expand Down
53 changes: 53 additions & 0 deletions src/sap_cloud_sdk/cache/__init__.py
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",
]
35 changes: 35 additions & 0 deletions src/sap_cloud_sdk/cache/_backend.py
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."""
175 changes: 175 additions & 0 deletions src/sap_cloud_sdk/cache/_cache.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
"""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.

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,
tenant_id: str,
user_id: str | None = None,
) -> Any | None:
"""Return a cached value, or ``None`` on miss or when disabled.

Args:
namespace: Domain namespace, e.g. ``"destination"``.
key: Domain-specific key, e.g. the destination name.
tenant_id: Tenant identifier for isolation key derivation.
user_id: Optional user identifier. Drives ``TENANT_USER``
isolation when present (and no explicit strategy override).
"""
if not self._config.enabled:
return None

full_key = self._make_full_key(namespace, key, tenant_id, user_id)
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,
tenant_id: str,
user_id: str | None = 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.
tenant_id: Tenant identifier for isolation key derivation.
user_id: Optional user identifier.
"""
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, tenant_id, user_id)
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,
tenant_id: str,
user_id: str | None = None,
) -> None:
"""Remove a single entry (no-op if absent or cache is disabled).

Args:
namespace: Domain namespace.
key: Domain-specific key.
tenant_id: Tenant identifier.
user_id: Optional user identifier.
"""
if not self._config.enabled:
return

full_key = self._make_full_key(namespace, key, tenant_id, user_id)
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,
tenant_id: str,
user_id: str | None,
) -> str:
isolation_key = build_isolation_key(
tenant_id=tenant_id,
user_id=user_id,
strategy=self._config.isolation_strategy,
)
return f"{namespace}::{isolation_key}::{key}"
67 changes: 67 additions & 0 deletions src/sap_cloud_sdk/cache/_config.py
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
Loading
Loading