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 @@ -39,6 +39,7 @@ dependencies = [
"mcp>=1.1.0,<3",
"python-odata>=0.7.0",
"cryptography>=48.0.1,<51",
"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."""
148 changes: 148 additions & 0 deletions src/sap_cloud_sdk/cache/_cache.py
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}"
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
78 changes: 78 additions & 0 deletions src/sap_cloud_sdk/cache/_isolation.py
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"

Copy link
Copy Markdown
Contributor

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.

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 ""
Loading
Loading