From 680ee76f7073b67805c7849830d0d9b4f9d6bca4 Mon Sep 17 00:00:00 2001 From: Cody Fincher Date: Mon, 15 Dec 2025 01:02:10 +0000 Subject: [PATCH 01/10] feat: support dogpile caching --- advanced_alchemy/_listeners.py | 210 ++++++ advanced_alchemy/cache/__init__.py | 87 +++ advanced_alchemy/cache/_null.py | 99 +++ advanced_alchemy/cache/config.py | 121 +++ advanced_alchemy/cache/manager.py | 713 ++++++++++++++++++ advanced_alchemy/cache/serializers.py | 218 ++++++ advanced_alchemy/repository/_async.py | 653 ++++++++++++++-- advanced_alchemy/repository/_sync.py | 651 ++++++++++++++-- advanced_alchemy/repository/memory/_async.py | 3 + advanced_alchemy/repository/memory/_sync.py | 3 + advanced_alchemy/service/_async.py | 10 +- advanced_alchemy/service/_sync.py | 10 +- pyproject.toml | 10 + tests/integration/test_cache_repository.py | 555 ++++++++++++++ tests/unit/test_cache/test_cache_config.py | 87 +++ tests/unit/test_cache/test_cache_manager.py | 429 +++++++++++ .../unit/test_cache/test_cache_serializers.py | 217 ++++++ 17 files changed, 3946 insertions(+), 130 deletions(-) create mode 100644 advanced_alchemy/cache/__init__.py create mode 100644 advanced_alchemy/cache/_null.py create mode 100644 advanced_alchemy/cache/config.py create mode 100644 advanced_alchemy/cache/manager.py create mode 100644 advanced_alchemy/cache/serializers.py create mode 100644 tests/integration/test_cache_repository.py create mode 100644 tests/unit/test_cache/test_cache_config.py create mode 100644 tests/unit/test_cache/test_cache_manager.py create mode 100644 tests/unit/test_cache/test_cache_serializers.py diff --git a/advanced_alchemy/_listeners.py b/advanced_alchemy/_listeners.py index a35c514e7..1e687ff0f 100644 --- a/advanced_alchemy/_listeners.py +++ b/advanced_alchemy/_listeners.py @@ -18,6 +18,8 @@ _active_file_operations: set[asyncio.Task[Any]] = set() """Stores active file operations to prevent them from being garbage collected.""" +_active_cache_operations: set[asyncio.Task[Any]] = set() +"""Stores active cache invalidation operations to prevent them from being garbage collected.""" # Context variable to hold the session tracker instance for the current session context _current_session_tracker: contextvars.ContextVar[Optional["FileObjectSessionTracker"]] = contextvars.ContextVar( "_current_session_tracker", @@ -444,6 +446,214 @@ def setup_file_object_listeners(registry: Optional["StorageRegistry"] = None) -> set_async_context(False) +# Cache invalidation support +_CACHE_TRACKER_KEY = "_aa_cache_tracker" + + +class CacheInvalidationTracker: + """Tracks pending cache invalidations for a session transaction. + + This tracker collects entity invalidations during a transaction and + processes them only after a successful commit. On rollback, the + pending invalidations are discarded. + + Note: + Model version bumps are also deferred to commit to ensure rollbacks + don't invalidate list caches when no DB change occurred. + """ + + __slots__ = ("_cache_manager", "_pending_invalidations", "_pending_model_bumps") + + def __init__(self, cache_manager: "CacheManager") -> None: + self._cache_manager = cache_manager + self._pending_invalidations: list[tuple[str, Any]] = [] + self._pending_model_bumps: set[str] = set() + + def add_invalidation(self, model_name: str, entity_id: Any) -> None: + """Queue an entity for cache invalidation. + + The actual invalidation and model version bump are deferred until + commit() is called, ensuring rollbacks don't affect the cache. + + Args: + model_name: The model/table name. + entity_id: The entity's primary key value. + """ + self._pending_invalidations.append((model_name, entity_id)) + # Queue model version bump for list query invalidation (deferred to commit) + self._pending_model_bumps.add(model_name) + + def commit(self) -> None: + """Process all pending invalidations after successful commit.""" + # First bump model versions for list query invalidation + for model_name in self._pending_model_bumps: + self._cache_manager.bump_model_version_sync(model_name) + self._pending_model_bumps.clear() + + # Then invalidate individual entities + for model_name, entity_id in self._pending_invalidations: + self._cache_manager.invalidate_entity_sync(model_name, entity_id) + self._pending_invalidations.clear() + + def rollback(self) -> None: + """Discard pending invalidations on rollback.""" + self._pending_invalidations.clear() + self._pending_model_bumps.clear() + + async def commit_async(self) -> None: + """Process all pending invalidations after successful commit (async-safe). + + This method performs cache I/O using the CacheManager async APIs so that + dogpile backends (often sync network clients) never block the event loop. + """ + # First bump model versions for list query invalidation + for model_name in self._pending_model_bumps: + await self._cache_manager.bump_model_version_async(model_name) + self._pending_model_bumps.clear() + + # Then invalidate individual entities + for model_name, entity_id in self._pending_invalidations: + await self._cache_manager.invalidate_entity_async(model_name, entity_id) + self._pending_invalidations.clear() + + +def get_cache_tracker( + session: "Union[Session, AsyncSession, scoped_session[Session], async_scoped_session[AsyncSession]]", + cache_manager: Optional["CacheManager"] = None, + create: bool = True, +) -> Optional["CacheInvalidationTracker"]: + """Get or create a cache invalidation tracker for the session. + + The tracker is stored on session.info to ensure proper scoping + per session instance and avoid ContextVar collisions. + + Args: + session: The SQLAlchemy session instance (sync or async, including scoped sessions). + cache_manager: The CacheManager instance (required if create=True). + create: Whether to create a new tracker if one doesn't exist. + + Returns: + The cache tracker or None if not available. + """ + tracker: Optional[CacheInvalidationTracker] = session.info.get(_CACHE_TRACKER_KEY) + if tracker is None and create and cache_manager is not None: + tracker = CacheInvalidationTracker(cache_manager) + session.info[_CACHE_TRACKER_KEY] = tracker + return tracker + + +class CacheInvalidationListener: + """Manages cache invalidation during SQLAlchemy Session transactions. + + This listener hooks into the SQLAlchemy Session event lifecycle to + handle cache invalidation in a transaction-safe manner. + + How it Works: + + 1. **Event Registration (`setup_cache_listeners`):** + Registers `after_commit` and `after_rollback` listeners globally + on the Session class. + + 2. **Tracking Changes:** + During mutations (add, update, delete), repositories call + `get_cache_tracker()` and add invalidations via `add_invalidation()`. + + 3. **Processing (`after_commit`):** + After successful commit, all pending invalidations are processed + and the tracker is cleared. + + 4. **Discarding (`after_rollback`):** + On rollback, pending invalidations are discarded without processing. + """ + + @classmethod + def _is_listener_enabled(cls, session: "Session") -> bool: + """Check if cache listener is enabled for this session.""" + enable_listener = True + + session_info = getattr(session, "info", {}) + if "enable_cache_listener" in session_info: + return bool(session_info["enable_cache_listener"]) + + options_sources: list[Optional[Union[Callable[[], dict[str, Any]], dict[str, Any]]]] = [] + if session.bind: + options_sources.append(getattr(session.bind, "execution_options", None)) + sync_engine = getattr(session.bind, "sync_engine", None) + if sync_engine: + options_sources.append(getattr(sync_engine, "execution_options", None)) + options_sources.append(getattr(session, "execution_options", None)) + + for options_source in options_sources: + if options_source is None: + continue + + options: Optional[dict[str, Any]] = None + if callable(options_source): + try: + result = options_source() + if isinstance(result, dict): # pyright: ignore + options = result + except Exception as e: + logger.debug("Error calling execution_options source: %s", e) + else: + options = options_source + + if options is not None and "enable_cache_listener" in options: + enable_listener = bool(options["enable_cache_listener"]) + break + + return enable_listener + + @classmethod + def after_commit(cls, session: "Session") -> None: + """Process cache invalidations after a successful commit.""" + if not cls._is_listener_enabled(session): + return + + tracker = get_cache_tracker(session, create=False) + if tracker: + try: + asyncio.get_running_loop() + except RuntimeError: + # No running loop: sync usage, perform invalidation inline. + tracker.commit() + else: + # Running loop: schedule async invalidation so commit doesn't block. + task = asyncio.create_task(tracker.commit_async()) + _active_cache_operations.add(task) + task.add_done_callback(_active_cache_operations.discard) + session.info.pop(_CACHE_TRACKER_KEY, None) + + @classmethod + def after_rollback(cls, session: "Session") -> None: + """Discard pending cache invalidations after a rollback.""" + tracker = get_cache_tracker(session, create=False) + if tracker: + tracker.rollback() + session.info.pop(_CACHE_TRACKER_KEY, None) + + +def setup_cache_listeners() -> None: + """Register cache invalidation event listeners globally. + + This should be called once during application initialization to enable + automatic cache invalidation for repositories using a CacheManager. + """ + from sqlalchemy.event import contains + from sqlalchemy.orm import Session + + listeners = { + "after_commit": CacheInvalidationListener.after_commit, + "after_rollback": CacheInvalidationListener.after_rollback, + } + + for event_name, listener_func in listeners.items(): + if not contains(Session, event_name, listener_func): + event.listen(Session, event_name, listener_func) + + logger.debug("Cache invalidation listeners registered") + + # Existing listener (keep it) def touch_updated_timestamp(session: "Session", *_: Any) -> None: # pragma: no cover """Set timestamp on update. diff --git a/advanced_alchemy/cache/__init__.py b/advanced_alchemy/cache/__init__.py new file mode 100644 index 000000000..400d4bcdf --- /dev/null +++ b/advanced_alchemy/cache/__init__.py @@ -0,0 +1,87 @@ +"""Dogpile.cache integration for Advanced Alchemy. + +This module provides optional caching support for SQLAlchemy repositories +using dogpile.cache. It supports multiple cache backends (Redis, Memcached, +file, memory) and provides automatic cache invalidation on model changes. + +Features: + - Multiple backend support (Redis, Memcached, file, memory, null) + - Commit-aware cache invalidation via SQLAlchemy events + - Version-based invalidation for list queries + - JSON serialization for cached models (configurable) + - Graceful degradation when dogpile.cache is not installed + - Per-process singleflight to reduce stampedes on cache miss + +Example: + Basic setup with memory cache:: + + from advanced_alchemy.cache import CacheConfig, CacheManager + from advanced_alchemy.repository import ( + SQLAlchemyAsyncRepository, + ) + + # Configure caching + cache_config = CacheConfig( + backend="dogpile.cache.memory", + expiration_time=300, # 5 minutes + ) + cache_manager = CacheManager(cache_config) + + + # Use with repository + class UserRepository(SQLAlchemyAsyncRepository[User]): + model_type = User + cache_config = cache_config + + + repo = UserRepository( + session=session, cache_manager=cache_manager + ) + user = await repo.get(1) # First call hits DB and caches + user = await repo.get( + 1 + ) # Second call returns cached result + + Redis configuration:: + + cache_config = CacheConfig( + backend="dogpile.cache.redis", + expiration_time=3600, + arguments={ + "host": "localhost", + "port": 6379, + "db": 0, + "distributed_lock": True, + }, + ) + +Note: + This module requires the optional ``dogpile.cache`` dependency. + Install with: ``pip install advanced-alchemy[dogpile]`` + + Without dogpile.cache installed, the cache manager will use a + NullRegion that provides the same interface but doesn't cache. + +Setup: + To enable automatic cache invalidation, call ``setup_cache_listeners()`` + during application initialization:: + + from advanced_alchemy.cache import setup_cache_listeners + + # Call once at startup + setup_cache_listeners() +""" + +from advanced_alchemy._listeners import setup_cache_listeners +from advanced_alchemy.cache.config import CacheConfig +from advanced_alchemy.cache.manager import DOGPILE_CACHE_INSTALLED, CacheManager +from advanced_alchemy.cache.serializers import default_deserializer, default_serializer + +__all__ = ( + "DOGPILE_CACHE_INSTALLED", + "CacheConfig", + "CacheManager", + "default_deserializer", + "default_serializer", + "setup_cache_listeners", +) diff --git a/advanced_alchemy/cache/_null.py b/advanced_alchemy/cache/_null.py new file mode 100644 index 000000000..4bcfa21a0 --- /dev/null +++ b/advanced_alchemy/cache/_null.py @@ -0,0 +1,99 @@ +"""Null cache region implementation for when dogpile.cache is not installed.""" + +from __future__ import annotations + +from typing import Any, Callable, TypeVar + +__all__ = ("NullRegion",) + +T = TypeVar("T") + + +# Sentinel value to indicate a cache miss (compatible with dogpile.cache.api.NO_VALUE) +class _NoValue: + def __repr__(self) -> str: + return "" + + +NO_VALUE: Any = _NoValue() + + +class NullRegion: + """A no-op cache region that provides the same interface as dogpile.cache.CacheRegion. + + This class is used when dogpile.cache is not installed or when caching + is disabled. All operations are no-ops that don't actually cache anything. + + The interface matches the subset of dogpile.cache.CacheRegion methods + that are used by the CacheManager. + """ + + __slots__ = () + + def get(self, key: str, expiration_time: int | None = None) -> Any: + """Get a value from the cache (always returns NO_VALUE). + + Args: + key: The cache key. + expiration_time: Ignored. + + Returns: + Always returns NO_VALUE to indicate a cache miss. + """ + return NO_VALUE + + def get_or_create( + self, + key: str, + creator: Callable[[], T], + expiration_time: int | None = None, + ) -> T: + """Get or create a value (always calls the creator). + + Args: + key: The cache key. + creator: Function to create the value. + expiration_time: Ignored. + + Returns: + The result of calling the creator function. + """ + return creator() + + def set(self, key: str, value: Any) -> None: + """Set a value in the cache (no-op). + + Args: + key: The cache key. + value: The value to cache. + """ + + def delete(self, key: str) -> None: + """Delete a value from the cache (no-op). + + Args: + key: The cache key to delete. + """ + + def invalidate(self) -> None: + """Invalidate all cached values (no-op).""" + + def configure( + self, + backend: str, + expiration_time: int | None = None, + arguments: dict[str, Any] | None = None, + **kwargs: Any, + ) -> NullRegion: + """Configure the region (no-op, returns self). + + Args: + backend: Ignored. + expiration_time: Ignored. + arguments: Ignored. + **kwargs: Ignored. + + Returns: + Self for method chaining. + """ + return self diff --git a/advanced_alchemy/cache/config.py b/advanced_alchemy/cache/config.py new file mode 100644 index 000000000..d0a007a51 --- /dev/null +++ b/advanced_alchemy/cache/config.py @@ -0,0 +1,121 @@ +"""Configuration classes for dogpile.cache integration.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any, Callable + +__all__ = ("CacheConfig",) + + +def _default_arguments() -> dict[str, Any]: + return {} + + +@dataclass +class CacheConfig: + """Configuration for a dogpile.cache region. + + This dataclass holds configuration options for setting up a cache region + using dogpile.cache. It supports multiple backends (Redis, Memcached, file, memory) + and provides sensible defaults for getting started quickly. + + Example: + Basic memory cache configuration:: + + config = CacheConfig( + backend="dogpile.cache.memory", + expiration_time=300, + ) + + Redis cache configuration:: + + config = CacheConfig( + backend="dogpile.cache.redis", + expiration_time=3600, + arguments={ + "host": "localhost", + "port": 6379, + "db": 0, + }, + ) + """ + + backend: str = "dogpile.cache.null" + """Cache backend identifier. + + Common backends: + - ``dogpile.cache.null``: No-op cache (default, for development) + - ``dogpile.cache.memory``: In-process memory cache + - ``dogpile.cache.redis``: Redis backend + - ``dogpile.cache.memcached``: Memcached backend + - ``dogpile.cache.dbm``: DBM file-based cache + """ + + expiration_time: int = 3600 + """Default TTL (time-to-live) in seconds for cached items. + + Set to ``-1`` for no expiration. Default is 3600 (1 hour). + """ + + arguments: dict[str, Any] = field(default_factory=_default_arguments) + """Backend-specific configuration arguments. + + These are passed directly to the dogpile.cache backend. + See dogpile.cache documentation for backend-specific options. + + Example for Redis:: + + {"host": "localhost", "port": 6379, "db": 0} + + Example for Memcached:: + + {"url": ["127.0.0.1:11211"]} + """ + + key_prefix: str = "aa:" + """Prefix for all cache keys. + + This helps avoid key collisions when sharing a cache backend + with other applications. Default is ``aa:`` (advanced-alchemy). + """ + + enabled: bool = True + """Enable or disable caching globally. + + When ``False``, all cache operations are bypassed and + data is always fetched from the database. Useful for + debugging or testing. + """ + + serializer: Callable[[Any], bytes] | None = None + """Custom serializer function. + + If ``None``, uses the default JSON serializer which handles + SQLAlchemy models, datetime objects, and UUIDs. + + The function should accept any value and return bytes. + """ + + deserializer: Callable[[bytes, type[Any]], Any] | None = None + """Custom deserializer function. + + If ``None``, uses the default JSON deserializer. + + The function should accept bytes and a model class, + returning an instance of that class. + """ + + region_factory: Callable[[CacheConfig], Any] | None = None + """Optional hook to construct a cache region instance. + + This exists to keep the repository/service integration stable even if + Advanced Alchemy swaps the underlying cache backend in the future. + + If provided, :class:`~advanced_alchemy.cache.CacheManager` will call this + factory instead of using ``dogpile.cache.make_region()``. + + The returned object must implement the subset of dogpile's region API that + AA relies on (e.g. ``get()``, ``set()``, ``delete()``, ``invalidate()``, + and optionally ``get_or_create()``). + """ diff --git a/advanced_alchemy/cache/manager.py b/advanced_alchemy/cache/manager.py new file mode 100644 index 000000000..92629436c --- /dev/null +++ b/advanced_alchemy/cache/manager.py @@ -0,0 +1,713 @@ +"""Cache manager for dogpile.cache integration with SQLAlchemy repositories.""" + +from __future__ import annotations + +import asyncio +import base64 +import concurrent.futures +import logging +import threading +import uuid +from functools import partial +from typing import TYPE_CHECKING, Any, Callable, TypeVar, cast + +from advanced_alchemy.cache._null import NO_VALUE, NullRegion +from advanced_alchemy.cache.serializers import default_deserializer, default_serializer +from advanced_alchemy.utils.sync_tools import async_ + +if TYPE_CHECKING: + from collections.abc import Coroutine + + from dogpile.cache import CacheRegion + + from advanced_alchemy.cache.config import CacheConfig + +__all__ = ( + "DOGPILE_CACHE_INSTALLED", + "CacheManager", +) + +logger = logging.getLogger("advanced_alchemy.cache") + +T = TypeVar("T") + +# Check if dogpile.cache is installed +_dogpile_cache_installed = False +_dogpile_no_value: Any = NO_VALUE +_make_region: Any = None +try: + from dogpile.cache import make_region as _make_region + from dogpile.cache.api import NO_VALUE as DOGPILE_CACHE_NO_VALUE + + _dogpile_cache_installed = True + _dogpile_no_value = DOGPILE_CACHE_NO_VALUE +except ImportError: + pass + +DOGPILE_CACHE_INSTALLED = _dogpile_cache_installed +DOGPILE_NO_VALUE = _dogpile_no_value + + +class CacheManager: + """Manages dogpile.cache regions with model-aware invalidation. + + This class provides a high-level interface for caching SQLAlchemy model + instances using dogpile.cache. It handles serialization, cache key + generation, and model-version-based invalidation for list queries. + + All cache operations are available in both sync and async variants: + - Sync methods end with ``_sync`` (e.g., ``get_sync``, ``set_sync``) + - Async methods end with ``_async`` (e.g., ``get_async``, ``set_async``) + + Async methods use ``asyncio.to_thread()`` with capacity limiting to + prevent blocking the event loop when using network-based backends + like Redis or Memcached. + + Features: + - Lazy initialization of cache regions + - Model-aware cache key generation + - Version-based invalidation for list queries + - Graceful degradation when dogpile.cache is not installed + - Support for custom serializers + - Both sync and async operation support + + Example: + Sync usage:: + + from advanced_alchemy.cache import CacheConfig, CacheManager + + config = CacheConfig( + backend="dogpile.cache.memory", + expiration_time=300, + ) + manager = CacheManager(config) + + # Cache a value (sync) + result = manager.get_or_create_sync( + "users:1", + lambda: fetch_user_from_db(1), + ) + + Async usage:: + + # Cache a value (async) + result = await manager.get_or_create_async( + "users:1", + lambda: fetch_user_from_db(1), + ) + """ + + __slots__ = ( + "_async_inflight", + "_async_inflight_lock", + "_model_versions", + "_region", + "_sync_inflight", + "_sync_inflight_lock", + "config", + ) + + def __init__(self, config: CacheConfig) -> None: + """Initialize the cache manager. + + Args: + config: Configuration for the cache region. + """ + self.config = config + self._region: CacheRegion | NullRegion | None = None + # Model version tokens are stored in-cache for cross-process consistency. + # Use a random token per commit to avoid lost updates without requiring + # backend-specific atomic increment support. + self._model_versions: dict[str, str] = {} + + # Per-process singleflight registries (async and sync). + # These are best-effort; they reduce stampedes within a single process. + self._async_inflight: dict[str, asyncio.Task[Any]] = {} + self._async_inflight_lock: asyncio.Lock | None = None + self._sync_inflight: dict[str, concurrent.futures.Future[Any]] = {} + self._sync_inflight_lock = threading.Lock() + + @property + def _inflight_lock_async(self) -> asyncio.Lock: + """Lazily create the asyncio lock used for async singleflight.""" + if self._async_inflight_lock is None: + self._async_inflight_lock = asyncio.Lock() + return self._async_inflight_lock + + @property + def region(self) -> CacheRegion | NullRegion: + """Get the cache region, creating it if necessary. + + The region is lazily initialized on first access. If dogpile.cache + is not installed or initialization fails, returns a NullRegion that + provides the same interface but doesn't actually cache anything. + + Returns: + The configured cache region or a NullRegion fallback. + """ + if self._region is None: + self._region = self._create_region() + return self._region + + def _create_region(self) -> CacheRegion | NullRegion: # noqa: PLR0911 + """Create and configure the dogpile.cache region. + + Returns: + A configured CacheRegion or NullRegion if dogpile.cache + is not installed or configuration fails. + """ + if self.config.region_factory is not None: + try: + created_region = cast("CacheRegion | NullRegion", self.config.region_factory(self.config)) + except Exception: + logger.exception("Failed to construct cache region via region_factory, using NullRegion") + return NullRegion() + else: + logger.debug("Configured cache region via region_factory") + return created_region + + if not DOGPILE_CACHE_INSTALLED: + logger.info("dogpile.cache is not installed, using NullRegion") + return NullRegion() + + if not self.config.enabled: + logger.debug("Caching is disabled, using NullRegion") + return NullRegion() + + try: + if _make_region is None: # pragma: no cover + return NullRegion() + region: CacheRegion = _make_region().configure( + self.config.backend, + expiration_time=self.config.expiration_time, + arguments=self.config.arguments, + ) + except Exception: + logger.exception("Failed to configure cache region, using NullRegion") + return NullRegion() + else: + logger.debug("Configured cache region with backend: %s", self.config.backend) + return region + + def _singleflight_async_cleanup(self, key: str, task: asyncio.Task[Any], *_: Any) -> None: + """Cleanup callback for async singleflight tasks. + + This runs in the event loop thread; we can safely mutate the in-flight + dict directly without scheduling another task. + """ + if self._async_inflight.get(key) is task: + self._async_inflight.pop(key, None) + + def _make_key(self, key: str) -> str: + """Generate a full cache key with the configured prefix. + + Args: + key: The base cache key. + + Returns: + The prefixed cache key. + """ + return f"{self.config.key_prefix}{key}" + + # ========================================================================= + # Sync Methods (canonical implementations) + # ========================================================================= + + def get_sync(self, key: str) -> object: + """Get a value from the cache (sync). + + Args: + key: The cache key (without prefix). + + Returns: + The cached value or NO_VALUE if not found. + """ + if not self.config.enabled: + return DOGPILE_NO_VALUE + return self.region.get(self._make_key(key)) + + def set_sync(self, key: str, value: Any) -> None: + """Set a value in the cache (sync). + + Args: + key: The cache key (without prefix). + value: The value to cache. + """ + if not self.config.enabled: + return + self.region.set(self._make_key(key), value) + + def delete_sync(self, key: str) -> None: + """Delete a value from the cache (sync). + + Args: + key: The cache key (without prefix). + """ + self.region.delete(self._make_key(key)) + + def get_or_create_sync( + self, + key: str, + creator: Callable[[], T], + expiration_time: int | None = None, + ) -> T: + """Get a value from cache or create it using the creator function (sync). + + This method uses dogpile.cache's get_or_create which provides + mutex-based protection against the "thundering herd" problem + when multiple requests try to create the same value simultaneously. + + Note: + The creator function must be synchronous. For async creators, + you must await them before passing to this method. + + Args: + key: The cache key (without prefix). + creator: A synchronous callable that returns the value to cache on miss. + expiration_time: Optional override for the default TTL. + + Returns: + The cached or newly created value. + """ + if not self.config.enabled: + return creator() + region = self.region + full_key = self._make_key(key) + if hasattr(region, "get_or_create"): + return region.get_or_create( + full_key, + creator, + expiration_time=expiration_time or self.config.expiration_time, + ) + cached = region.get(full_key) + if cached is not DOGPILE_NO_VALUE and cached is not NO_VALUE: + return cast("T", cached) + value = creator() + region.set(full_key, value) + return value + + def get_entity_sync( + self, + model_name: str, + entity_id: Any, + model_class: type[T], + ) -> T | None: + """Get a cached entity by model name and ID (sync). + + Args: + model_name: The model/table name. + entity_id: The entity's primary key value. + model_class: The SQLAlchemy model class for deserialization. + + Returns: + The cached model instance or None if not found. + """ + key = f"{model_name}:get:{entity_id}" + cached = self.get_sync(key) + + if cached is DOGPILE_NO_VALUE or cached is NO_VALUE: + return None + if not isinstance(cached, (bytes, bytearray)): + self.delete_sync(key) + return None + + deserializer = self.config.deserializer or default_deserializer + try: + payload = bytes(cached) if isinstance(cached, bytearray) else cached + result: T = deserializer(payload, model_class) + except Exception: + logger.exception("Failed to deserialize cached entity %s:%s", model_name, entity_id) + # Remove corrupted cache entry + self.delete_sync(key) + return None + else: + return result + + def set_entity_sync( + self, + model_name: str, + entity_id: Any, + entity: Any, + ) -> None: + """Cache an entity (sync). + + Args: + model_name: The model/table name. + entity_id: The entity's primary key value. + entity: The SQLAlchemy model instance to cache. + """ + key = f"{model_name}:get:{entity_id}" + serializer = self.config.serializer or default_serializer + + try: + serialized = serializer(entity) + self.set_sync(key, serialized) + except Exception: + logger.exception("Failed to serialize entity %s:%s", model_name, entity_id) + + def invalidate_entity_sync(self, model_name: str, entity_id: Any) -> None: + """Invalidate the cache for a specific entity (sync). + + This should be called after an entity is created, updated, or deleted + to ensure the cache doesn't serve stale data. + + Args: + model_name: The model/table name. + entity_id: The entity's primary key value. + """ + key = f"{model_name}:get:{entity_id}" + self.delete_sync(key) + logger.debug("Invalidated cache for %s:%s", model_name, entity_id) + + def bump_model_version_sync(self, model_name: str) -> str: + """Bump the version token for a model (sync). + + This is used for version-based invalidation of list queries. + When a model is created, updated, or deleted, the version is + bumped to a new random token, which effectively invalidates all + list query caches that include that token in their cache key. + + Args: + model_name: The model/table name. + + Returns: + The new version token. + """ + token = uuid.uuid4().hex + self._model_versions[model_name] = token + + # Store in cache for distributed consistency + self.set_sync(f"{model_name}:version", token) + logger.debug("Bumped version token for %s to %s", model_name, token) + return token + + def get_model_version_sync(self, model_name: str) -> str: + """Get the current version token for a model (sync). + + This is used to include the version in list query cache keys, + ensuring that list caches are invalidated when models change. + + Args: + model_name: The model/table name. + + Returns: + The current version token ("0" if not set). + """ + # Check local cache first + if model_name in self._model_versions: + return self._model_versions[model_name] + + # Check distributed cache + cached = self.get_sync(f"{model_name}:version") + if cached is not DOGPILE_NO_VALUE and cached is not NO_VALUE and isinstance(cached, str): + self._model_versions[model_name] = cached + return cached + + return "0" + + def get_list_sync(self, key: str, model_class: type[T]) -> list[T] | None: + """Get a cached list of entities (sync). + + The list is stored as base64-encoded serialized entity payloads. + + Args: + key: Cache key (without prefix). + model_class: Model class for deserialization. + + Returns: + A list of detached model instances or None if not found. + """ + cached = self.get_sync(key) + if cached is DOGPILE_NO_VALUE or cached is NO_VALUE: + return None + if not isinstance(cached, list): + return None + + cached_list = cast("list[Any]", cached) # type: ignore[redundant-cast] + deserializer = self.config.deserializer or default_deserializer + results: list[T] = [] + try: + for item in cached_list: + if not isinstance(item, str): + return None + raw = base64.b64decode(item.encode("ascii")) + results.append(deserializer(raw, model_class)) + except Exception: + logger.exception("Failed to deserialize cached list for key %s", key) + self.delete_sync(key) + return None + return results + + def set_list_sync(self, key: str, items: list[Any]) -> None: + """Cache a list of entities (sync). + + Args: + key: Cache key (without prefix). + items: List of entities to cache. + """ + serializer = self.config.serializer or default_serializer + try: + payload = [base64.b64encode(serializer(item)).decode("ascii") for item in items] + self.set_sync(key, payload) + except Exception: + logger.exception("Failed to serialize cached list for key %s", key) + + def get_list_and_count_sync(self, key: str, model_class: type[T]) -> tuple[list[T], int] | None: + """Get a cached list+count payload (sync).""" + cached = self.get_sync(key) + if cached is DOGPILE_NO_VALUE or cached is NO_VALUE: + return None + if not isinstance(cached, dict): + return None + + cached_payload: dict[str, Any] = cast("dict[str, Any]", cached) + items_raw = cached_payload.get("items") + count_raw = cached_payload.get("count") + if not isinstance(items_raw, list) or not isinstance(count_raw, int): + return None + + items_list = cast("list[Any]", items_raw) # type: ignore[redundant-cast] + deserializer = self.config.deserializer or default_deserializer + results: list[T] = [] + try: + for item in items_list: + if not isinstance(item, str): + return None + raw = base64.b64decode(item.encode("ascii")) + results.append(deserializer(raw, model_class)) + except Exception: + logger.exception("Failed to deserialize cached list_and_count for key %s", key) + self.delete_sync(key) + return None + return results, count_raw + + def set_list_and_count_sync(self, key: str, items: list[Any], count: int) -> None: + """Cache a list+count payload (sync).""" + serializer = self.config.serializer or default_serializer + try: + payload = { + "items": [base64.b64encode(serializer(item)).decode("ascii") for item in items], + "count": count, + } + self.set_sync(key, payload) + except Exception: + logger.exception("Failed to serialize cached list_and_count for key %s", key) + + def singleflight_sync(self, key: str, creator: Callable[[], T]) -> T: + """Coalesce concurrent sync cache misses per-process. + + This reduces stampedes in thread-based sync apps. It does not provide + cross-process locking. + """ + with self._sync_inflight_lock: + future: concurrent.futures.Future[Any] | None = self._sync_inflight.get(key) + if future is None: + future = concurrent.futures.Future() + self._sync_inflight[key] = future + is_owner = True + else: + is_owner = False + + if not is_owner: + return cast("T", future.result()) + + try: + result = creator() + except Exception as e: + future.set_exception(e) + raise + else: + future.set_result(result) + return result + finally: + with self._sync_inflight_lock: + if self._sync_inflight.get(key) is future: + self._sync_inflight.pop(key, None) + + def invalidate_all_sync(self) -> None: + """Invalidate all cached values (sync). + + Warning: + This invalidates the entire region, not just keys with + the configured prefix. Use with caution in shared cache + environments. + """ + self.region.invalidate() + self._model_versions.clear() + logger.info("Invalidated entire cache region") + + # ========================================================================= + # Async Methods (thin wrappers using async_() for thread offloading) + # ========================================================================= + + async def get_async(self, key: str) -> object: + """Get a value from the cache (async). + + Args: + key: The cache key (without prefix). + + Returns: + The cached value or NO_VALUE if not found. + """ + return await async_(self.get_sync)(key) + + async def set_async(self, key: str, value: Any) -> None: + """Set a value in the cache (async). + + Args: + key: The cache key (without prefix). + value: The value to cache. + """ + await async_(self.set_sync)(key, value) + + async def delete_async(self, key: str) -> None: + """Delete a value from the cache (async). + + Args: + key: The cache key (without prefix). + """ + await async_(self.delete_sync)(key) + + async def get_or_create_async( + self, + key: str, + creator: Callable[[], T], + expiration_time: int | None = None, + ) -> T: + """Get a value from cache or create it using the creator function (async). + + This method uses dogpile.cache's get_or_create which provides + mutex-based protection against the "thundering herd" problem + when multiple requests try to create the same value simultaneously. + + Note: + The creator function must be synchronous since dogpile.cache + runs in a thread pool. For async creators, you must await + them and wrap the result before passing to this method. + + Args: + key: The cache key (without prefix). + creator: A synchronous callable that returns the value to cache on miss. + expiration_time: Optional override for the default TTL. + + Returns: + The cached or newly created value. + """ + return await async_(self.get_or_create_sync)(key, creator, expiration_time) + + async def get_entity_async( + self, + model_name: str, + entity_id: Any, + model_class: type[T], + ) -> T | None: + """Get a cached entity by model name and ID (async). + + Args: + model_name: The model/table name. + entity_id: The entity's primary key value. + model_class: The SQLAlchemy model class for deserialization. + + Returns: + The cached model instance or None if not found. + """ + return await async_(self.get_entity_sync)(model_name, entity_id, model_class) + + async def set_entity_async( + self, + model_name: str, + entity_id: Any, + entity: Any, + ) -> None: + """Cache an entity (async). + + Args: + model_name: The model/table name. + entity_id: The entity's primary key value. + entity: The SQLAlchemy model instance to cache. + """ + await async_(self.set_entity_sync)(model_name, entity_id, entity) + + async def invalidate_entity_async(self, model_name: str, entity_id: Any) -> None: + """Invalidate the cache for a specific entity (async). + + This should be called after an entity is created, updated, or deleted + to ensure the cache doesn't serve stale data. + + Args: + model_name: The model/table name. + entity_id: The entity's primary key value. + """ + await async_(self.invalidate_entity_sync)(model_name, entity_id) + + async def bump_model_version_async(self, model_name: str) -> str: + """Bump the version token for a model (async). + + This is used for version-based invalidation of list queries. + When a model is created, updated, or deleted, the version is + bumped, which effectively invalidates all list query caches + that include that model's version in their cache key. + + Args: + model_name: The model/table name. + + Returns: + The new version token. + """ + return await async_(self.bump_model_version_sync)(model_name) + + async def get_model_version_async(self, model_name: str) -> str: + """Get the current version token for a model (async). + + This is used to include the version in list query cache keys, + ensuring that list caches are invalidated when models change. + + Args: + model_name: The model/table name. + + Returns: + The current version token ("0" if not set). + """ + return await async_(self.get_model_version_sync)(model_name) + + async def get_list_async(self, key: str, model_class: type[T]) -> list[T] | None: + """Get a cached list of entities (async).""" + return await async_(self.get_list_sync)(key, model_class) + + async def set_list_async(self, key: str, items: list[Any]) -> None: + """Cache a list of entities (async).""" + await async_(self.set_list_sync)(key, items) + + async def get_list_and_count_async(self, key: str, model_class: type[T]) -> tuple[list[T], int] | None: + """Get a cached list+count payload (async).""" + return await async_(self.get_list_and_count_sync)(key, model_class) + + async def set_list_and_count_async(self, key: str, items: list[Any], count: int) -> None: + """Cache a list+count payload (async).""" + await async_(self.set_list_and_count_sync)(key, items, count) + + async def singleflight_async(self, key: str, creator: Callable[[], Coroutine[Any, Any, T]]) -> T: + """Coalesce concurrent async cache misses per-process. + + The creator is invoked once per key at a time; concurrent callers + await the same in-flight task. This does not provide cross-process + locking. + """ + async with self._inflight_lock_async: + task = self._async_inflight.get(key) + if task is None: + task = asyncio.create_task(creator()) + self._async_inflight[key] = task + task.add_done_callback(partial(self._singleflight_async_cleanup, key)) + + return await asyncio.shield(task) + + async def invalidate_all_async(self) -> None: + """Invalidate all cached values (async). + + Warning: + This invalidates the entire region, not just keys with + the configured prefix. Use with caution in shared cache + environments. + """ + await async_(self.invalidate_all_sync)() diff --git a/advanced_alchemy/cache/serializers.py b/advanced_alchemy/cache/serializers.py new file mode 100644 index 000000000..9d5c40622 --- /dev/null +++ b/advanced_alchemy/cache/serializers.py @@ -0,0 +1,218 @@ +"""Serialization utilities for caching SQLAlchemy models.""" + +from __future__ import annotations + +from datetime import date, datetime, time, timedelta +from decimal import Decimal +from typing import Any, TypeVar, cast +from uuid import UUID + +from advanced_alchemy._serialization import decode_json, encode_json + +__all__ = ( + "default_deserializer", + "default_serializer", +) + +T = TypeVar("T") + +# Metadata keys used in serialized data +_MODEL_KEY = "__aa_model__" +_TABLE_KEY = "__aa_table__" + + +def _json_encoder(obj: Any) -> Any: # noqa: PLR0911 + """Custom JSON encoder for SQLAlchemy model attributes. + + Handles types that are not natively JSON serializable: + - datetime, date, time: ISO format strings + - timedelta: total seconds as float + - Decimal: string representation + - bytes: hex string + - UUID: string representation + - set, frozenset: list + + Args: + obj: The object to encode. + + Returns: + A JSON-serializable representation of the object. + + Raises: + TypeError: If the object type is not supported. + """ + if isinstance(obj, datetime): + return {"__type__": "datetime", "value": obj.isoformat()} + if isinstance(obj, date): + return {"__type__": "date", "value": obj.isoformat()} + if isinstance(obj, time): + return {"__type__": "time", "value": obj.isoformat()} + if isinstance(obj, timedelta): + return {"__type__": "timedelta", "value": obj.total_seconds()} + if isinstance(obj, Decimal): + return {"__type__": "decimal", "value": str(obj)} + if isinstance(obj, bytes): + return {"__type__": "bytes", "value": obj.hex()} + if isinstance(obj, UUID): + return {"__type__": "uuid", "value": str(obj)} + if isinstance(obj, (set, frozenset)): + items: list[Any] = list(cast("set[Any] | frozenset[Any]", obj)) # type: ignore[redundant-cast] + return {"__type__": "set", "value": items} + msg = f"Object of type {type(obj).__name__} is not JSON serializable" + raise TypeError(msg) + + +def _json_decoder(obj: dict[str, Any]) -> Any: # noqa: PLR0911 + """Custom JSON decoder for special types. + + Args: + obj: The dictionary to decode. + + Returns: + The decoded object, or the original dict if not a special type. + """ + if "__type__" not in obj: + return obj + + type_name = obj["__type__"] + value = obj["value"] + + if type_name == "datetime": + return datetime.fromisoformat(value) + if type_name == "date": + return date.fromisoformat(value) + if type_name == "time": + return time.fromisoformat(value) + if type_name == "timedelta": + return timedelta(seconds=value) + if type_name == "decimal": + return Decimal(value) + if type_name == "bytes": + return bytes.fromhex(value) + if type_name == "uuid": + return UUID(value) + if type_name == "set": + return set(value) + + return obj + + +def _decode_special_types(value: Any) -> Any: + """Recursively decode special type markers. + + When using ``encode_json`` (msgspec/orjson/json fallback), we can't rely on + stdlib json's ``object_hook`` callback. This helper decodes the special + ``{"__type__": ..., "value": ...}`` structures produced by ``_json_encoder``. + """ + if isinstance(value, list): + value_list = cast("list[Any]", value) # type: ignore[redundant-cast] + return [_decode_special_types(v) for v in value_list] + + if not isinstance(value, dict): + return value + + # Decode any nested values first + value_dict = cast("dict[Any, Any]", value) # type: ignore[redundant-cast] + decoded: dict[str, Any] = {str(k): _decode_special_types(v) for k, v in value_dict.items()} + + # Then decode "typed" marker dicts + if "__type__" in decoded and "value" in decoded: + return _json_decoder(decoded) + + return decoded + + +def default_serializer(model: Any) -> bytes: + """Serialize a SQLAlchemy model instance to JSON bytes. + + This function extracts column values from a SQLAlchemy model and + serializes them to JSON format. The serialized data includes metadata + about the model class for validation during deserialization. + + Note: + Relationships are NOT serialized. Only column values are included. + The deserialized object will be a detached instance without + relationship data loaded. + + Args: + model: The SQLAlchemy model instance to serialize. + + Returns: + JSON-encoded bytes representation of the model. + + Example: + Serializing a model:: + + user = User(id=1, name="John", email="john@example.com") + data = default_serializer(user) + # b'{"__aa_model__": "User", "__aa_table__": "users", "id": 1, ...}' + """ + from sqlalchemy import inspect as sa_inspect + + mapper = sa_inspect(model.__class__) + data: dict[str, Any] = { + _MODEL_KEY: model.__class__.__name__, + _TABLE_KEY: model.__class__.__tablename__, + } + + for column in mapper.columns: + # Skip internal SQLAlchemy sentinel columns (e.g., sa_orm_sentinel) + if getattr(column, "_insert_sentinel", False): + continue + value = getattr(model, column.key) + try: + # Encode special types into JSON-friendly marker structures. + data[column.key] = _json_encoder(value) + except TypeError: + # Leave unknown types alone; encode_json has its own hooks/fallbacks. + data[column.key] = value + + return encode_json(data).encode("utf-8") + + +def default_deserializer(data: bytes, model_class: type[T]) -> T: + """Deserialize JSON bytes to a SQLAlchemy model instance. + + Creates a new, detached instance of the model class populated with + the serialized column values. The instance is NOT attached to any + session and should be treated as a read-only snapshot. + + Warning: + The returned instance is detached and does not have relationships + loaded. Accessing lazy-loaded relationships will raise + DetachedInstanceError. Use ``session.merge()`` if you need to + work with relationships. + + Args: + data: JSON bytes to deserialize. + model_class: The SQLAlchemy model class to instantiate. + + Returns: + A new, detached instance of the model class. + + Raises: + ValueError: If the serialized data is for a different model class. + + Example: + Deserializing data:: + + data = b'{"__aa_model__": "User", "id": 1, "name": "John"}' + user = default_deserializer(data, User) + # user is a detached User instance + """ + parsed_raw = decode_json(data) + parsed = _decode_special_types(parsed_raw) + + # Validate model class matches + serialized_model = parsed.pop(_MODEL_KEY, None) + parsed.pop(_TABLE_KEY, None) # Remove table key, not needed for instantiation + + if serialized_model and serialized_model != model_class.__name__: + msg = f"Cannot deserialize {serialized_model} data as {model_class.__name__}" + raise ValueError(msg) + + # Create detached instance using constructor + # This properly initializes SQLAlchemy's ORM state + instance: T = model_class(**parsed) + + return instance diff --git a/advanced_alchemy/repository/_async.py b/advanced_alchemy/repository/_async.py index 68b894980..519ceb3d8 100644 --- a/advanced_alchemy/repository/_async.py +++ b/advanced_alchemy/repository/_async.py @@ -1,9 +1,12 @@ import contextlib +import dataclasses import datetime import decimal +import hashlib import random import string -from collections.abc import Iterable, Sequence +from collections.abc import Iterable, Mapping, Sequence +from functools import partial from typing import ( TYPE_CHECKING, Any, @@ -39,8 +42,10 @@ from sqlalchemy.orm.strategy_options import _AbstractLoad # pyright: ignore[reportPrivateUsage] from sqlalchemy.sql import ColumnElement from sqlalchemy.sql.dml import ReturningDelete, ReturningUpdate +from sqlalchemy.sql.elements import UnaryExpression from sqlalchemy.sql.selectable import ForUpdateArg, ForUpdateParameter +from advanced_alchemy._serialization import encode_json from advanced_alchemy.exceptions import ErrorMessages, NotFoundError, RepositoryError, wrap_sqlalchemy_exception from advanced_alchemy.filters import StatementFilter, StatementTypeT from advanced_alchemy.repository._util import ( @@ -78,6 +83,128 @@ } +def _sort_kv_by_key_str(kv: tuple[object, object]) -> str: + return str(kv[0]) + + +def _sort_normalized_value(value: Any) -> str: + return encode_json(_canonicalize_cache_key_value(value)) + + +def _normalize_cache_key_value(value: Any) -> Any: + """Normalize values into a deterministic JSON-serializable form. + + Used for list/list_and_count cache keys. + """ + if value is None or isinstance(value, (int, float, str, bool)): + return value + + result: Any + if isinstance(value, bytes): + result = {"__bytes__": value.hex()} + elif isinstance(value, (datetime.datetime, datetime.date, datetime.time)): + result = {"__datetime__": value.isoformat()} + elif isinstance(value, datetime.timedelta): + result = {"__timedelta__": value.total_seconds()} + elif isinstance(value, decimal.Decimal): + result = {"__decimal__": str(value)} + elif isinstance(value, set): + value_set = cast("set[Any]", value) # type: ignore[redundant-cast] + normalized = [_normalize_cache_key_value(v) for v in value_set] + normalized.sort(key=_sort_normalized_value) + result = normalized + elif isinstance(value, (list, tuple)): + value_seq = cast("Sequence[Any]", value) + result = [_normalize_cache_key_value(v) for v in value_seq] + elif isinstance(value, dict): + value_dict = cast("dict[object, object]", value) + normalized_dict: dict[str, Any] = {} + for k, v in sorted(value_dict.items(), key=_sort_kv_by_key_str): + normalized_dict[str(k)] = _normalize_cache_key_value(v) + result = normalized_dict + elif dataclasses.is_dataclass(value) and not isinstance(value, type): + result = _normalize_cache_key_value(dataclasses.asdict(value)) + elif isinstance(value, InstrumentedAttribute): + result = {"__attr__": value.key} + elif isinstance(value, ColumnElement): + # Safe fallback for non-dataclass expressions in ordering/kwargs. + value_expr = cast("ColumnElement[Any]", value) # type: ignore[redundant-cast] + result = {"__sql__": str(value_expr)} + else: + result = {"__repr__": repr(value)} + + return result + + +def _canonicalize_cache_key_value(value: Any) -> Any: + """Convert dict-like objects into a stable, ordered representation.""" + if isinstance(value, Mapping): + value_map = cast("Mapping[object, object]", value) + return [ + [str(k), _canonicalize_cache_key_value(v)] for k, v in sorted(value_map.items(), key=_sort_kv_by_key_str) + ] + if isinstance(value, list): + value_list = cast("list[Any]", value) # type: ignore[redundant-cast] + return [_canonicalize_cache_key_value(v) for v in value_list] + return value + + +def _build_list_cache_key( + *, + model_name: str, + version_token: str, + method: str, + filters: Sequence[Union[StatementFilter, ColumnElement[bool]]], + kwargs: dict[str, Any], + order_by: Optional[Union[list[OrderingPair], OrderingPair]], + execution_options: dict[str, Any], + uniquify: bool, + count_with_window_function: Optional[bool] = None, +) -> Optional[str]: + """Build a stable cache key for list/list_and_count operations. + + Returns None if the query specification includes non-cacheable filter + expressions (e.g., raw SQLAlchemy boolean expressions). + """ + normalized_filters: list[dict[str, Any]] = [] + for filter_ in filters: + if isinstance(filter_, ColumnElement): + return None + normalized_filters.append({"type": filter_.__class__.__name__, "data": _normalize_cache_key_value(filter_)}) + + normalized_order_by: Optional[list[Any]] = None + if order_by is not None: + order_items = order_by if isinstance(order_by, list) else [order_by] + normalized_order_by = [] + for item in order_items: + if isinstance(item, UnaryExpression): + normalized_order_by.append({"expr": str(item)}) + else: + col, desc = item + normalized_order_by.append({"col": _normalize_cache_key_value(col), "desc": bool(desc)}) + + payload: dict[str, Any] = { + "method": method, + "model": model_name, + "version": version_token, + "filters": normalized_filters, + "kwargs": _normalize_cache_key_value(kwargs), + "order_by": normalized_order_by, + "execution_options": _normalize_cache_key_value(execution_options), + "uniquify": uniquify, + } + if count_with_window_function is not None: + payload["count_with_window_function"] = bool(count_with_window_function) + + try: + encoded = encode_json(_canonicalize_cache_key_value(payload)).encode("utf-8") + except TypeError: # pragma: no cover + return None + + digest = hashlib.sha256(encoded).hexdigest() + return f"{model_name}:{method}:{digest}" + + @runtime_checkable class SQLAlchemyAsyncRepositoryProtocol(FilterableRepositoryProtocol[ModelT], Protocol[ModelT]): """Base Protocol""" @@ -347,6 +474,7 @@ async def list_and_count( load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, order_by: Optional[Union[list[OrderingPair], OrderingPair]] = None, + use_cache: bool = True, bind_group: Optional[str] = None, **kwargs: Any, ) -> tuple[list[ModelT], int]: ... @@ -360,6 +488,7 @@ async def list( load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, order_by: Optional[Union[list[OrderingPair], OrderingPair]] = None, + use_cache: bool = True, bind_group: Optional[str] = None, **kwargs: Any, ) -> list[ModelT]: ... @@ -530,6 +659,26 @@ def _get_uniquify(self, uniquify: Optional[bool] = None) -> bool: """ return bool(uniquify) if uniquify is not None else self._uniquify + def _queue_cache_invalidation(self, entity_id: Any) -> None: + """Queue a cache invalidation for an entity. + + The invalidation will be processed after the transaction commits. + If the transaction rolls back, the pending invalidation is discarded. + + This uses the global CacheInvalidationListener which must be set up + via setup_cache_listeners() during application initialization. + + Args: + entity_id: The primary key value of the entity to invalidate. + """ + if self._cache_manager is not None: + from advanced_alchemy._listeners import get_cache_tracker + + model_name = cast("str", self.model_type.__tablename__) # type: ignore[attr-defined] + tracker = get_cache_tracker(self.session, self._cache_manager) + if tracker is not None: + tracker.add_invalidation(model_name, entity_id) + def _type_must_use_in_instead_of_any(self, matched_values: "list[Any]", field_type: "Any" = None) -> bool: """Determine if field.in_() should be used instead of any_() for compatibility. @@ -969,6 +1118,7 @@ async def delete_where( load=load, execution_options=execution_options, auto_expunge=auto_expunge, + use_cache=False, # Always fetch from DB for delete_where **kwargs, ), ) @@ -983,6 +1133,8 @@ async def delete_where( await self._flush_or_commit(auto_commit=auto_commit) for instance in instances: self._expunge(instance, auto_expunge=auto_expunge) + # Queue cache invalidation (processed on commit) + self._queue_cache_invalidation(self.get_id_attribute_value(instance)) return instances async def exists( @@ -1100,6 +1252,253 @@ def _get_delete_many_statement( return statement.where(id_attribute.in_(id_chunk)) # pyright: ignore[reportUnknownMemberType,reportUnknownVariableType] return statement.where(any_(id_chunk) == id_attribute) # type: ignore[arg-type] + async def _get_uncached_impl( + self, + item_id: Any, + *, + auto_expunge: Optional[bool], + statement: Optional[Select[tuple[ModelT]]], + id_attribute: Optional[Union[str, InstrumentedAttribute[Any]]], + error_messages: Optional[ErrorMessages], + load: Optional[LoadSpec], + execution_options: Optional[dict[str, Any]], + with_for_update: ForUpdateParameter, + ) -> ModelT: + """Fetch an entity from the database without using cache.""" + with wrap_sqlalchemy_exception( + error_messages=error_messages, dialect_name=self._dialect.name, wrap_exceptions=self.wrap_exceptions + ): + resolved_execution_options = self._get_execution_options(execution_options) + resolved_statement = self.statement if statement is None else statement + loader_options, loader_options_have_wildcard = self._get_loader_options(load) + resolved_id_attribute = id_attribute if id_attribute is not None else self.id_attribute + resolved_statement = self._get_base_stmt( + statement=resolved_statement, + loader_options=loader_options, + execution_options=resolved_execution_options, + ) + resolved_statement = self._filter_select_by_kwargs(resolved_statement, [(resolved_id_attribute, item_id)]) + resolved_statement = self._apply_for_update_options(resolved_statement, with_for_update) + instance = ( + await self._execute(resolved_statement, uniquify=loader_options_have_wildcard) + ).scalar_one_or_none() + instance = self.check_not_found(instance) + self._expunge(instance, auto_expunge=auto_expunge) + return instance + + async def _get_cached_creator( + self, + model_name: str, + item_id: Any, + *, + auto_expunge: Optional[bool], + statement: Optional[Select[tuple[ModelT]]], + id_attribute: Optional[Union[str, InstrumentedAttribute[Any]]], + error_messages: Optional[ErrorMessages], + load: Optional[LoadSpec], + execution_options: Optional[dict[str, Any]], + with_for_update: ForUpdateParameter, + ) -> ModelT: + """Singleflight creator for get(id) caching (async).""" + if self._cache_manager is None: + return await self._get_uncached_impl( + item_id, + auto_expunge=auto_expunge, + statement=statement, + id_attribute=id_attribute, + error_messages=error_messages, + load=load, + execution_options=execution_options, + with_for_update=with_for_update, + ) + + existing = await self._cache_manager.get_entity_async(model_name, item_id, self.model_type) + if existing is not None: + return existing + + instance = await self._get_uncached_impl( + item_id, + auto_expunge=auto_expunge, + statement=statement, + id_attribute=id_attribute, + error_messages=error_messages, + load=load, + execution_options=execution_options, + with_for_update=with_for_update, + ) + await self._cache_manager.set_entity_async(model_name, item_id, instance) + return instance + + async def _list_uncached_impl( + self, + *, + filters: Sequence[Union[StatementFilter, ColumnElement[bool]]], + auto_expunge: Optional[bool], + statement: Optional[Select[tuple[ModelT]]], + order_by: Optional[Union[list[OrderingPair], OrderingPair]], + error_messages: Optional[ErrorMessages], + load: Optional[LoadSpec], + execution_options: Optional[dict[str, Any]], + kwargs: dict[str, Any], + uniquify: Optional[bool], + ) -> list[ModelT]: + """Fetch a list of entities from the database without using cache.""" + self._uniquify = self._get_uniquify(uniquify) + with wrap_sqlalchemy_exception( + error_messages=error_messages, dialect_name=self._dialect.name, wrap_exceptions=self.wrap_exceptions + ): + resolved_execution_options = self._get_execution_options(execution_options) + resolved_statement = self.statement if statement is None else statement + loader_options, loader_options_have_wildcard = self._get_loader_options(load) + resolved_statement = self._get_base_stmt( + statement=resolved_statement, + loader_options=loader_options, + execution_options=resolved_execution_options, + ) + if order_by is None: + order_by = self.order_by if self.order_by is not None else [] + resolved_statement = self._apply_order_by(statement=resolved_statement, order_by=order_by) + resolved_statement = self._apply_filters(*filters, statement=resolved_statement) + resolved_statement = self._filter_select_by_kwargs(resolved_statement, kwargs) + result = await self._execute(resolved_statement, uniquify=loader_options_have_wildcard) + instances = list(result.scalars()) + for instance in instances: + self._expunge(instance, auto_expunge=auto_expunge) + return cast("list[ModelT]", instances) + + async def _list_cached_creator( + self, + cache_key: str, + *, + filters: Sequence[Union[StatementFilter, ColumnElement[bool]]], + auto_expunge: Optional[bool], + statement: Optional[Select[tuple[ModelT]]], + order_by: Optional[Union[list[OrderingPair], OrderingPair]], + error_messages: Optional[ErrorMessages], + load: Optional[LoadSpec], + execution_options: Optional[dict[str, Any]], + kwargs: dict[str, Any], + uniquify: Optional[bool], + ) -> list[ModelT]: + """Singleflight creator for list caching (async).""" + if self._cache_manager is None: + return await self._list_uncached_impl( + filters=filters, + auto_expunge=auto_expunge, + statement=statement, + order_by=order_by, + error_messages=error_messages, + load=load, + execution_options=execution_options, + kwargs=kwargs, + uniquify=uniquify, + ) + + existing = await self._cache_manager.get_list_async(cache_key, self.model_type) + if existing is not None: + return existing + + instances = await self._list_uncached_impl( + filters=filters, + auto_expunge=auto_expunge, + statement=statement, + order_by=order_by, + error_messages=error_messages, + load=load, + execution_options=execution_options, + kwargs=kwargs, + uniquify=uniquify, + ) + await self._cache_manager.set_list_async(cache_key, list(instances)) + return list(instances) + + async def _list_and_count_uncached_impl( + self, + *, + filters: Sequence[Union[StatementFilter, ColumnElement[bool]]], + auto_expunge: Optional[bool], + statement: Optional[Select[tuple[ModelT]]], + count_with_window_function: bool, + order_by: Optional[Union[list[OrderingPair], OrderingPair]], + error_messages: Optional[ErrorMessages], + load: Optional[LoadSpec], + execution_options: Optional[dict[str, Any]], + kwargs: dict[str, Any], + uniquify: Optional[bool], + ) -> tuple[list[ModelT], int]: + """Fetch a list+count payload from the database without using cache.""" + self._uniquify = self._get_uniquify(uniquify) + if self._dialect.name in {"spanner", "spanner+spanner"} or not count_with_window_function: + return await self._list_and_count_basic( + *filters, + auto_expunge=auto_expunge, + statement=statement, + load=load, + execution_options=execution_options, + order_by=order_by, + error_messages=error_messages, + **kwargs, + ) + return await self._list_and_count_window( + *filters, + auto_expunge=auto_expunge, + statement=statement, + load=load, + execution_options=execution_options, + error_messages=error_messages, + order_by=order_by, + **kwargs, + ) + + async def _list_and_count_cached_creator( + self, + cache_key: str, + *, + filters: Sequence[Union[StatementFilter, ColumnElement[bool]]], + auto_expunge: Optional[bool], + statement: Optional[Select[tuple[ModelT]]], + count_with_window_function: bool, + order_by: Optional[Union[list[OrderingPair], OrderingPair]], + error_messages: Optional[ErrorMessages], + load: Optional[LoadSpec], + execution_options: Optional[dict[str, Any]], + kwargs: dict[str, Any], + uniquify: Optional[bool], + ) -> tuple[list[ModelT], int]: + """Singleflight creator for list_and_count caching (async).""" + if self._cache_manager is None: + return await self._list_and_count_uncached_impl( + filters=filters, + auto_expunge=auto_expunge, + statement=statement, + count_with_window_function=count_with_window_function, + order_by=order_by, + error_messages=error_messages, + load=load, + execution_options=execution_options, + kwargs=kwargs, + uniquify=uniquify, + ) + + existing = await self._cache_manager.get_list_and_count_async(cache_key, self.model_type) + if existing is not None: + return existing + + instances, count = await self._list_and_count_uncached_impl( + filters=filters, + auto_expunge=auto_expunge, + statement=statement, + count_with_window_function=count_with_window_function, + order_by=order_by, + error_messages=error_messages, + load=load, + execution_options=execution_options, + kwargs=kwargs, + uniquify=uniquify, + ) + await self._cache_manager.set_list_and_count_async(cache_key, list(instances), count) + return list(instances), count + async def get( self, item_id: Any, @@ -1134,31 +1533,60 @@ async def get( The retrieved instance. """ self._uniquify = self._get_uniquify(uniquify) - error_messages = self._get_error_messages( + resolved_error_messages = self._get_error_messages( error_messages=error_messages, default_messages=self.error_messages, ) - with wrap_sqlalchemy_exception( - error_messages=error_messages, dialect_name=self._dialect.name, wrap_exceptions=self.wrap_exceptions + + resolved_auto_expunge = self.auto_expunge if auto_expunge is None else auto_expunge + resolved_id_attribute: Optional[Union[str, InstrumentedAttribute[Any]]] = id_attribute + if isinstance(resolved_id_attribute, InstrumentedAttribute): + resolved_id_attribute = resolved_id_attribute.key + + cache_manager = self._cache_manager + if ( + use_cache + and cache_manager is not None + and bool(resolved_auto_expunge) + and statement is None + and load is None + and with_for_update is None + and (resolved_id_attribute is None or resolved_id_attribute == self.id_attribute) + and not self._default_loader_options + and not self._default_execution_options + and execution_options is None ): - if bind_group: - execution_options = dict(execution_options) if execution_options else {} - execution_options["bind_group"] = bind_group - execution_options = self._get_execution_options(execution_options) - statement = self.statement if statement is None else statement - loader_options, loader_options_have_wildcard = self._get_loader_options(load) - id_attribute = id_attribute if id_attribute is not None else self.id_attribute - statement = self._get_base_stmt( - statement=statement, - loader_options=loader_options, - execution_options=execution_options, + model_name = cast("str", self.model_type.__tablename__) # type: ignore[attr-defined] + cached = await cache_manager.get_entity_async(model_name, item_id, self.model_type) + if cached is not None: + return cached + + return await cache_manager.singleflight_async( + f"{model_name}:get:{item_id}", + partial( + self._get_cached_creator, + model_name, + item_id, + auto_expunge=auto_expunge, + statement=statement, + id_attribute=resolved_id_attribute, + error_messages=resolved_error_messages, + load=load, + execution_options=execution_options, + with_for_update=with_for_update, + ), ) - statement = self._filter_select_by_kwargs(statement, [(id_attribute, item_id)]) - statement = self._apply_for_update_options(statement, with_for_update) - instance = (await self._execute(statement, uniquify=loader_options_have_wildcard)).scalar_one_or_none() - instance = self.check_not_found(instance) - self._expunge(instance, auto_expunge=auto_expunge) - return instance + + return await self._get_uncached_impl( + item_id, + auto_expunge=auto_expunge, + statement=statement, + id_attribute=id_attribute, + error_messages=resolved_error_messages, + load=load, + execution_options=execution_options, + with_for_update=with_for_update, + ) async def get_one( self, @@ -1737,6 +2165,7 @@ async def list_and_count( load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, + use_cache: bool = True, bind_group: Optional[str] = None, **kwargs: Any, ) -> tuple[list[ModelT], int]: @@ -1753,7 +2182,7 @@ async def list_and_count( load: Set relationships to be loaded execution_options: Set default execution options uniquify: Optionally apply the ``unique()`` method to results before returning. - bind_group: Optional routing group to use for the operation. + use_cache: Whether to use the cache for this query. Defaults to ``True``. **kwargs: Instance attribute value filters. Returns: @@ -1763,32 +2192,84 @@ async def list_and_count( count_with_window_function if count_with_window_function is not None else self.count_with_window_function ) self._uniquify = self._get_uniquify(uniquify) - error_messages = self._get_error_messages( + resolved_error_messages = self._get_error_messages( error_messages=error_messages, default_messages=self.error_messages, ) - if self._dialect.name in {"spanner", "spanner+spanner"} or not count_with_window_function: - return await self._list_and_count_basic( - *filters, + + resolved_auto_expunge = self.auto_expunge if auto_expunge is None else auto_expunge + resolved_execution_options = self._get_execution_options(execution_options) + resolved_order_by = order_by if order_by is not None else (self.order_by if self.order_by is not None else []) + + cache_manager = self._cache_manager + if not ( + use_cache + and bool(resolved_auto_expunge) + and cache_manager is not None + and statement is None + and load is None + and not self._default_loader_options + ): + return await self._list_and_count_uncached_impl( + filters=filters, auto_expunge=auto_expunge, statement=statement, + count_with_window_function=count_with_window_function, + order_by=order_by, + error_messages=resolved_error_messages, load=load, execution_options=execution_options, + kwargs=kwargs, + uniquify=uniquify, + ) + + model_name = cast("str", self.model_type.__tablename__) # type: ignore[attr-defined] + version_token = await cache_manager.get_model_version_async(model_name) + cache_key = _build_list_cache_key( + model_name=model_name, + version_token=version_token, + method="list_and_count", + filters=filters, + kwargs=kwargs, + order_by=resolved_order_by, + execution_options=resolved_execution_options, + uniquify=self._uniquify, + count_with_window_function=count_with_window_function, + ) + if cache_key is None: + return await self._list_and_count_uncached_impl( + filters=filters, + auto_expunge=auto_expunge, + statement=statement, + count_with_window_function=count_with_window_function, order_by=order_by, - error_messages=error_messages, - bind_group=bind_group, - **kwargs, + error_messages=resolved_error_messages, + load=load, + execution_options=execution_options, + kwargs=kwargs, + uniquify=uniquify, ) - return await self._list_and_count_window( - *filters, - auto_expunge=auto_expunge, - statement=statement, - load=load, - execution_options=execution_options, - error_messages=error_messages, - order_by=order_by, - bind_group=bind_group, - **kwargs, + + cached = await cache_manager.get_list_and_count_async(cache_key, self.model_type) + if cached is not None: + return cached + + return await cache_manager.singleflight_async( + cache_key, + partial( + self._list_and_count_cached_creator, + cache_key, + filters=filters, + auto_expunge=auto_expunge, + statement=statement, + count_with_window_function=count_with_window_function, + order_by=order_by, + error_messages=resolved_error_messages, + load=load, + execution_options=execution_options, + kwargs=kwargs, + uniquify=uniquify, + ), ) def _expunge(self, instance: "ModelT", auto_expunge: "Optional[bool]") -> None: @@ -2076,6 +2557,8 @@ async def upsert( auto_refresh=auto_refresh, ) self._expunge(instance, auto_expunge=auto_expunge) + # Queue cache invalidation (processed on commit) + self._queue_cache_invalidation(self.get_id_attribute_value(instance)) return instance async def upsert_many( @@ -2224,6 +2707,7 @@ async def list( load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, + use_cache: bool = True, bind_group: Optional[str] = None, **kwargs: Any, ) -> list[ModelT]: @@ -2239,41 +2723,88 @@ async def list( load: Set relationships to be loaded execution_options: Set default execution options uniquify: Optionally apply the ``unique()`` method to results before returning. - bind_group: Optional routing group to use for the operation. + use_cache: Whether to use the cache for this query. Defaults to ``True``. **kwargs: Instance attribute value filters. Returns: The list of instances, after filtering applied. """ self._uniquify = self._get_uniquify(uniquify) - error_messages = self._get_error_messages( + resolved_error_messages = self._get_error_messages( error_messages=error_messages, default_messages=self.error_messages, ) - with wrap_sqlalchemy_exception( - error_messages=error_messages, dialect_name=self._dialect.name, wrap_exceptions=self.wrap_exceptions + + resolved_auto_expunge = self.auto_expunge if auto_expunge is None else auto_expunge + resolved_execution_options = self._get_execution_options(execution_options) + resolved_order_by = order_by if order_by is not None else (self.order_by if self.order_by is not None else []) + + cache_manager = self._cache_manager + if not ( + use_cache + and bool(resolved_auto_expunge) + and cache_manager is not None + and statement is None + and load is None + and not self._default_loader_options ): - if bind_group: - execution_options = dict(execution_options) if execution_options else {} - execution_options["bind_group"] = bind_group - execution_options = self._get_execution_options(execution_options) - statement = self.statement if statement is None else statement - loader_options, loader_options_have_wildcard = self._get_loader_options(load) - statement = self._get_base_stmt( + return await self._list_uncached_impl( + filters=filters, + auto_expunge=auto_expunge, statement=statement, - loader_options=loader_options, + order_by=order_by, + error_messages=resolved_error_messages, + load=load, execution_options=execution_options, + kwargs=kwargs, + uniquify=uniquify, ) - if order_by is None: - order_by = self.order_by if self.order_by is not None else [] - statement = self._apply_order_by(statement=statement, order_by=order_by) - statement = self._apply_filters(*filters, statement=statement) - statement = self._filter_select_by_kwargs(statement, kwargs) - result = await self._execute(statement, uniquify=loader_options_have_wildcard) - instances = list(result.scalars()) - for instance in instances: - self._expunge(instance, auto_expunge=auto_expunge) - return cast("list[ModelT]", instances) + + model_name = cast("str", self.model_type.__tablename__) # type: ignore[attr-defined] + version_token = await cache_manager.get_model_version_async(model_name) + cache_key = _build_list_cache_key( + model_name=model_name, + version_token=version_token, + method="list", + filters=filters, + kwargs=kwargs, + order_by=resolved_order_by, + execution_options=resolved_execution_options, + uniquify=self._uniquify, + ) + if cache_key is None: + return await self._list_uncached_impl( + filters=filters, + auto_expunge=auto_expunge, + statement=statement, + order_by=order_by, + error_messages=resolved_error_messages, + load=load, + execution_options=execution_options, + kwargs=kwargs, + uniquify=uniquify, + ) + + cached = await cache_manager.get_list_async(cache_key, self.model_type) + if cached is not None: + return cached + + return await cache_manager.singleflight_async( + cache_key, + partial( + self._list_cached_creator, + cache_key, + filters=filters, + auto_expunge=auto_expunge, + statement=statement, + order_by=order_by, + error_messages=resolved_error_messages, + load=load, + execution_options=execution_options, + kwargs=kwargs, + uniquify=uniquify, + ), + ) @classmethod async def check_health(cls, session: Union[AsyncSession, async_scoped_session[AsyncSession]]) -> bool: diff --git a/advanced_alchemy/repository/_sync.py b/advanced_alchemy/repository/_sync.py index d433d39a8..84177015b 100644 --- a/advanced_alchemy/repository/_sync.py +++ b/advanced_alchemy/repository/_sync.py @@ -1,11 +1,14 @@ # Do not edit this file directly. It has been autogenerated from # advanced_alchemy/repository/_async.py import contextlib +import dataclasses import datetime import decimal +import hashlib import random import string -from collections.abc import Iterable, Sequence +from collections.abc import Iterable, Mapping, Sequence +from functools import partial from typing import ( TYPE_CHECKING, Any, @@ -40,8 +43,10 @@ from sqlalchemy.orm.strategy_options import _AbstractLoad # pyright: ignore[reportPrivateUsage] from sqlalchemy.sql import ColumnElement from sqlalchemy.sql.dml import ReturningDelete, ReturningUpdate +from sqlalchemy.sql.elements import UnaryExpression from sqlalchemy.sql.selectable import ForUpdateArg, ForUpdateParameter +from advanced_alchemy._serialization import encode_json from advanced_alchemy.exceptions import ErrorMessages, NotFoundError, RepositoryError, wrap_sqlalchemy_exception from advanced_alchemy.filters import StatementFilter, StatementTypeT from advanced_alchemy.repository._util import ( @@ -79,6 +84,128 @@ } +def _sort_kv_by_key_str(kv: tuple[object, object]) -> str: + return str(kv[0]) + + +def _sort_normalized_value(value: Any) -> str: + return encode_json(_canonicalize_cache_key_value(value)) + + +def _normalize_cache_key_value(value: Any) -> Any: + """Normalize values into a deterministic JSON-serializable form. + + Used for list/list_and_count cache keys. + """ + if value is None or isinstance(value, (int, float, str, bool)): + return value + + result: Any + if isinstance(value, bytes): + result = {"__bytes__": value.hex()} + elif isinstance(value, (datetime.datetime, datetime.date, datetime.time)): + result = {"__datetime__": value.isoformat()} + elif isinstance(value, datetime.timedelta): + result = {"__timedelta__": value.total_seconds()} + elif isinstance(value, decimal.Decimal): + result = {"__decimal__": str(value)} + elif isinstance(value, set): + value_set = cast("set[Any]", value) # type: ignore[redundant-cast] + normalized = [_normalize_cache_key_value(v) for v in value_set] + normalized.sort(key=_sort_normalized_value) + result = normalized + elif isinstance(value, (list, tuple)): + value_seq = cast("Sequence[Any]", value) + result = [_normalize_cache_key_value(v) for v in value_seq] + elif isinstance(value, dict): + value_dict = cast("dict[object, object]", value) + normalized_dict: dict[str, Any] = {} + for k, v in sorted(value_dict.items(), key=_sort_kv_by_key_str): + normalized_dict[str(k)] = _normalize_cache_key_value(v) + result = normalized_dict + elif dataclasses.is_dataclass(value) and not isinstance(value, type): + result = _normalize_cache_key_value(dataclasses.asdict(value)) + elif isinstance(value, InstrumentedAttribute): + result = {"__attr__": value.key} + elif isinstance(value, ColumnElement): + # Safe fallback for non-dataclass expressions in ordering/kwargs. + value_expr = cast("ColumnElement[Any]", value) # type: ignore[redundant-cast] + result = {"__sql__": str(value_expr)} + else: + result = {"__repr__": repr(value)} + + return result + + +def _canonicalize_cache_key_value(value: Any) -> Any: + """Convert dict-like objects into a stable, ordered representation.""" + if isinstance(value, Mapping): + value_map = cast("Mapping[object, object]", value) + return [ + [str(k), _canonicalize_cache_key_value(v)] for k, v in sorted(value_map.items(), key=_sort_kv_by_key_str) + ] + if isinstance(value, list): + value_list = cast("list[Any]", value) # type: ignore[redundant-cast] + return [_canonicalize_cache_key_value(v) for v in value_list] + return value + + +def _build_list_cache_key( + *, + model_name: str, + version_token: str, + method: str, + filters: Sequence[Union[StatementFilter, ColumnElement[bool]]], + kwargs: dict[str, Any], + order_by: Optional[Union[list[OrderingPair], OrderingPair]], + execution_options: dict[str, Any], + uniquify: bool, + count_with_window_function: Optional[bool] = None, +) -> Optional[str]: + """Build a stable cache key for list/list_and_count operations. + + Returns None if the query specification includes non-cacheable filter + expressions (e.g., raw SQLAlchemy boolean expressions). + """ + normalized_filters: list[dict[str, Any]] = [] + for filter_ in filters: + if isinstance(filter_, ColumnElement): + return None + normalized_filters.append({"type": filter_.__class__.__name__, "data": _normalize_cache_key_value(filter_)}) + + normalized_order_by: Optional[list[Any]] = None + if order_by is not None: + order_items = order_by if isinstance(order_by, list) else [order_by] + normalized_order_by = [] + for item in order_items: + if isinstance(item, UnaryExpression): + normalized_order_by.append({"expr": str(item)}) + else: + col, desc = item + normalized_order_by.append({"col": _normalize_cache_key_value(col), "desc": bool(desc)}) + + payload: dict[str, Any] = { + "method": method, + "model": model_name, + "version": version_token, + "filters": normalized_filters, + "kwargs": _normalize_cache_key_value(kwargs), + "order_by": normalized_order_by, + "execution_options": _normalize_cache_key_value(execution_options), + "uniquify": uniquify, + } + if count_with_window_function is not None: + payload["count_with_window_function"] = bool(count_with_window_function) + + try: + encoded = encode_json(_canonicalize_cache_key_value(payload)).encode("utf-8") + except TypeError: # pragma: no cover + return None + + digest = hashlib.sha256(encoded).hexdigest() + return f"{model_name}:{method}:{digest}" + + @runtime_checkable class SQLAlchemySyncRepositoryProtocol(FilterableRepositoryProtocol[ModelT], Protocol[ModelT]): """Base Protocol""" @@ -348,6 +475,7 @@ def list_and_count( load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, order_by: Optional[Union[list[OrderingPair], OrderingPair]] = None, + use_cache: bool = True, bind_group: Optional[str] = None, **kwargs: Any, ) -> tuple[list[ModelT], int]: ... @@ -361,6 +489,7 @@ def list( load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, order_by: Optional[Union[list[OrderingPair], OrderingPair]] = None, + use_cache: bool = True, bind_group: Optional[str] = None, **kwargs: Any, ) -> list[ModelT]: ... @@ -531,6 +660,26 @@ def _get_uniquify(self, uniquify: Optional[bool] = None) -> bool: """ return bool(uniquify) if uniquify is not None else self._uniquify + def _queue_cache_invalidation(self, entity_id: Any) -> None: + """Queue a cache invalidation for an entity. + + The invalidation will be processed after the transaction commits. + If the transaction rolls back, the pending invalidation is discarded. + + This uses the global CacheInvalidationListener which must be set up + via setup_cache_listeners() during application initialization. + + Args: + entity_id: The primary key value of the entity to invalidate. + """ + if self._cache_manager is not None: + from advanced_alchemy._listeners import get_cache_tracker + + model_name = cast("str", self.model_type.__tablename__) # type: ignore[attr-defined] + tracker = get_cache_tracker(self.session, self._cache_manager) + if tracker is not None: + tracker.add_invalidation(model_name, entity_id) + def _type_must_use_in_instead_of_any(self, matched_values: "list[Any]", field_type: "Any" = None) -> bool: """Determine if field.in_() should be used instead of any_() for compatibility. @@ -970,6 +1119,7 @@ def delete_where( load=load, execution_options=execution_options, auto_expunge=auto_expunge, + use_cache=False, # Always fetch from DB for delete_where **kwargs, ), ) @@ -984,6 +1134,8 @@ def delete_where( self._flush_or_commit(auto_commit=auto_commit) for instance in instances: self._expunge(instance, auto_expunge=auto_expunge) + # Queue cache invalidation (processed on commit) + self._queue_cache_invalidation(self.get_id_attribute_value(instance)) return instances def exists( @@ -1101,6 +1253,251 @@ def _get_delete_many_statement( return statement.where(id_attribute.in_(id_chunk)) # pyright: ignore[reportUnknownMemberType,reportUnknownVariableType] return statement.where(any_(id_chunk) == id_attribute) # type: ignore[arg-type] + def _get_uncached_impl( + self, + item_id: Any, + *, + auto_expunge: Optional[bool], + statement: Optional[Select[tuple[ModelT]]], + id_attribute: Optional[Union[str, InstrumentedAttribute[Any]]], + error_messages: Optional[ErrorMessages], + load: Optional[LoadSpec], + execution_options: Optional[dict[str, Any]], + with_for_update: ForUpdateParameter, + ) -> ModelT: + """Fetch an entity from the database without using cache.""" + with wrap_sqlalchemy_exception( + error_messages=error_messages, dialect_name=self._dialect.name, wrap_exceptions=self.wrap_exceptions + ): + resolved_execution_options = self._get_execution_options(execution_options) + resolved_statement = self.statement if statement is None else statement + loader_options, loader_options_have_wildcard = self._get_loader_options(load) + resolved_id_attribute = id_attribute if id_attribute is not None else self.id_attribute + resolved_statement = self._get_base_stmt( + statement=resolved_statement, + loader_options=loader_options, + execution_options=resolved_execution_options, + ) + resolved_statement = self._filter_select_by_kwargs(resolved_statement, [(resolved_id_attribute, item_id)]) + resolved_statement = self._apply_for_update_options(resolved_statement, with_for_update) + instance = (self._execute(resolved_statement, uniquify=loader_options_have_wildcard)).scalar_one_or_none() + instance = self.check_not_found(instance) + self._expunge(instance, auto_expunge=auto_expunge) + return instance + + def _get_cached_creator( + self, + model_name: str, + item_id: Any, + *, + auto_expunge: Optional[bool], + statement: Optional[Select[tuple[ModelT]]], + id_attribute: Optional[Union[str, InstrumentedAttribute[Any]]], + error_messages: Optional[ErrorMessages], + load: Optional[LoadSpec], + execution_options: Optional[dict[str, Any]], + with_for_update: ForUpdateParameter, + ) -> ModelT: + """Singleflight creator for get(id) caching (async).""" + if self._cache_manager is None: + return self._get_uncached_impl( + item_id, + auto_expunge=auto_expunge, + statement=statement, + id_attribute=id_attribute, + error_messages=error_messages, + load=load, + execution_options=execution_options, + with_for_update=with_for_update, + ) + + existing = self._cache_manager.get_entity_sync(model_name, item_id, self.model_type) + if existing is not None: + return existing + + instance = self._get_uncached_impl( + item_id, + auto_expunge=auto_expunge, + statement=statement, + id_attribute=id_attribute, + error_messages=error_messages, + load=load, + execution_options=execution_options, + with_for_update=with_for_update, + ) + self._cache_manager.set_entity_sync(model_name, item_id, instance) + return instance + + def _list_uncached_impl( + self, + *, + filters: Sequence[Union[StatementFilter, ColumnElement[bool]]], + auto_expunge: Optional[bool], + statement: Optional[Select[tuple[ModelT]]], + order_by: Optional[Union[list[OrderingPair], OrderingPair]], + error_messages: Optional[ErrorMessages], + load: Optional[LoadSpec], + execution_options: Optional[dict[str, Any]], + kwargs: dict[str, Any], + uniquify: Optional[bool], + ) -> list[ModelT]: + """Fetch a list of entities from the database without using cache.""" + self._uniquify = self._get_uniquify(uniquify) + with wrap_sqlalchemy_exception( + error_messages=error_messages, dialect_name=self._dialect.name, wrap_exceptions=self.wrap_exceptions + ): + resolved_execution_options = self._get_execution_options(execution_options) + resolved_statement = self.statement if statement is None else statement + loader_options, loader_options_have_wildcard = self._get_loader_options(load) + resolved_statement = self._get_base_stmt( + statement=resolved_statement, + loader_options=loader_options, + execution_options=resolved_execution_options, + ) + if order_by is None: + order_by = self.order_by if self.order_by is not None else [] + resolved_statement = self._apply_order_by(statement=resolved_statement, order_by=order_by) + resolved_statement = self._apply_filters(*filters, statement=resolved_statement) + resolved_statement = self._filter_select_by_kwargs(resolved_statement, kwargs) + result = self._execute(resolved_statement, uniquify=loader_options_have_wildcard) + instances = list(result.scalars()) + for instance in instances: + self._expunge(instance, auto_expunge=auto_expunge) + return cast("list[ModelT]", instances) + + def _list_cached_creator( + self, + cache_key: str, + *, + filters: Sequence[Union[StatementFilter, ColumnElement[bool]]], + auto_expunge: Optional[bool], + statement: Optional[Select[tuple[ModelT]]], + order_by: Optional[Union[list[OrderingPair], OrderingPair]], + error_messages: Optional[ErrorMessages], + load: Optional[LoadSpec], + execution_options: Optional[dict[str, Any]], + kwargs: dict[str, Any], + uniquify: Optional[bool], + ) -> list[ModelT]: + """Singleflight creator for list caching (async).""" + if self._cache_manager is None: + return self._list_uncached_impl( + filters=filters, + auto_expunge=auto_expunge, + statement=statement, + order_by=order_by, + error_messages=error_messages, + load=load, + execution_options=execution_options, + kwargs=kwargs, + uniquify=uniquify, + ) + + existing = self._cache_manager.get_list_sync(cache_key, self.model_type) + if existing is not None: + return existing + + instances = self._list_uncached_impl( + filters=filters, + auto_expunge=auto_expunge, + statement=statement, + order_by=order_by, + error_messages=error_messages, + load=load, + execution_options=execution_options, + kwargs=kwargs, + uniquify=uniquify, + ) + self._cache_manager.set_list_sync(cache_key, list(instances)) + return list(instances) + + def _list_and_count_uncached_impl( + self, + *, + filters: Sequence[Union[StatementFilter, ColumnElement[bool]]], + auto_expunge: Optional[bool], + statement: Optional[Select[tuple[ModelT]]], + count_with_window_function: bool, + order_by: Optional[Union[list[OrderingPair], OrderingPair]], + error_messages: Optional[ErrorMessages], + load: Optional[LoadSpec], + execution_options: Optional[dict[str, Any]], + kwargs: dict[str, Any], + uniquify: Optional[bool], + ) -> tuple[list[ModelT], int]: + """Fetch a list+count payload from the database without using cache.""" + self._uniquify = self._get_uniquify(uniquify) + if self._dialect.name in {"spanner", "spanner+spanner"} or not count_with_window_function: + return self._list_and_count_basic( + *filters, + auto_expunge=auto_expunge, + statement=statement, + load=load, + execution_options=execution_options, + order_by=order_by, + error_messages=error_messages, + **kwargs, + ) + return self._list_and_count_window( + *filters, + auto_expunge=auto_expunge, + statement=statement, + load=load, + execution_options=execution_options, + error_messages=error_messages, + order_by=order_by, + **kwargs, + ) + + def _list_and_count_cached_creator( + self, + cache_key: str, + *, + filters: Sequence[Union[StatementFilter, ColumnElement[bool]]], + auto_expunge: Optional[bool], + statement: Optional[Select[tuple[ModelT]]], + count_with_window_function: bool, + order_by: Optional[Union[list[OrderingPair], OrderingPair]], + error_messages: Optional[ErrorMessages], + load: Optional[LoadSpec], + execution_options: Optional[dict[str, Any]], + kwargs: dict[str, Any], + uniquify: Optional[bool], + ) -> tuple[list[ModelT], int]: + """Singleflight creator for list_and_count caching (async).""" + if self._cache_manager is None: + return self._list_and_count_uncached_impl( + filters=filters, + auto_expunge=auto_expunge, + statement=statement, + count_with_window_function=count_with_window_function, + order_by=order_by, + error_messages=error_messages, + load=load, + execution_options=execution_options, + kwargs=kwargs, + uniquify=uniquify, + ) + + existing = self._cache_manager.get_list_and_count_sync(cache_key, self.model_type) + if existing is not None: + return existing + + instances, count = self._list_and_count_uncached_impl( + filters=filters, + auto_expunge=auto_expunge, + statement=statement, + count_with_window_function=count_with_window_function, + order_by=order_by, + error_messages=error_messages, + load=load, + execution_options=execution_options, + kwargs=kwargs, + uniquify=uniquify, + ) + self._cache_manager.set_list_and_count_sync(cache_key, list(instances), count) + return list(instances), count + def get( self, item_id: Any, @@ -1135,31 +1532,60 @@ def get( The retrieved instance. """ self._uniquify = self._get_uniquify(uniquify) - error_messages = self._get_error_messages( + resolved_error_messages = self._get_error_messages( error_messages=error_messages, default_messages=self.error_messages, ) - with wrap_sqlalchemy_exception( - error_messages=error_messages, dialect_name=self._dialect.name, wrap_exceptions=self.wrap_exceptions + + resolved_auto_expunge = self.auto_expunge if auto_expunge is None else auto_expunge + resolved_id_attribute: Optional[Union[str, InstrumentedAttribute[Any]]] = id_attribute + if isinstance(resolved_id_attribute, InstrumentedAttribute): + resolved_id_attribute = resolved_id_attribute.key + + cache_manager = self._cache_manager + if ( + use_cache + and cache_manager is not None + and bool(resolved_auto_expunge) + and statement is None + and load is None + and with_for_update is None + and (resolved_id_attribute is None or resolved_id_attribute == self.id_attribute) + and not self._default_loader_options + and not self._default_execution_options + and execution_options is None ): - if bind_group: - execution_options = dict(execution_options) if execution_options else {} - execution_options["bind_group"] = bind_group - execution_options = self._get_execution_options(execution_options) - statement = self.statement if statement is None else statement - loader_options, loader_options_have_wildcard = self._get_loader_options(load) - id_attribute = id_attribute if id_attribute is not None else self.id_attribute - statement = self._get_base_stmt( - statement=statement, - loader_options=loader_options, - execution_options=execution_options, + model_name = cast("str", self.model_type.__tablename__) # type: ignore[attr-defined] + cached = cache_manager.get_entity_sync(model_name, item_id, self.model_type) + if cached is not None: + return cached + + return cache_manager.singleflight_sync( + f"{model_name}:get:{item_id}", + partial( + self._get_cached_creator, + model_name, + item_id, + auto_expunge=auto_expunge, + statement=statement, + id_attribute=resolved_id_attribute, + error_messages=resolved_error_messages, + load=load, + execution_options=execution_options, + with_for_update=with_for_update, + ), ) - statement = self._filter_select_by_kwargs(statement, [(id_attribute, item_id)]) - statement = self._apply_for_update_options(statement, with_for_update) - instance = (self._execute(statement, uniquify=loader_options_have_wildcard)).scalar_one_or_none() - instance = self.check_not_found(instance) - self._expunge(instance, auto_expunge=auto_expunge) - return instance + + return self._get_uncached_impl( + item_id, + auto_expunge=auto_expunge, + statement=statement, + id_attribute=id_attribute, + error_messages=resolved_error_messages, + load=load, + execution_options=execution_options, + with_for_update=with_for_update, + ) def get_one( self, @@ -1738,6 +2164,7 @@ def list_and_count( load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, + use_cache: bool = True, bind_group: Optional[str] = None, **kwargs: Any, ) -> tuple[list[ModelT], int]: @@ -1754,7 +2181,7 @@ def list_and_count( load: Set relationships to be loaded execution_options: Set default execution options uniquify: Optionally apply the ``unique()`` method to results before returning. - bind_group: Optional routing group to use for the operation. + use_cache: Whether to use the cache for this query. Defaults to ``True``. **kwargs: Instance attribute value filters. Returns: @@ -1764,32 +2191,84 @@ def list_and_count( count_with_window_function if count_with_window_function is not None else self.count_with_window_function ) self._uniquify = self._get_uniquify(uniquify) - error_messages = self._get_error_messages( + resolved_error_messages = self._get_error_messages( error_messages=error_messages, default_messages=self.error_messages, ) - if self._dialect.name in {"spanner", "spanner+spanner"} or not count_with_window_function: - return self._list_and_count_basic( - *filters, + + resolved_auto_expunge = self.auto_expunge if auto_expunge is None else auto_expunge + resolved_execution_options = self._get_execution_options(execution_options) + resolved_order_by = order_by if order_by is not None else (self.order_by if self.order_by is not None else []) + + cache_manager = self._cache_manager + if not ( + use_cache + and bool(resolved_auto_expunge) + and cache_manager is not None + and statement is None + and load is None + and not self._default_loader_options + ): + return self._list_and_count_uncached_impl( + filters=filters, auto_expunge=auto_expunge, statement=statement, + count_with_window_function=count_with_window_function, + order_by=order_by, + error_messages=resolved_error_messages, load=load, execution_options=execution_options, + kwargs=kwargs, + uniquify=uniquify, + ) + + model_name = cast("str", self.model_type.__tablename__) # type: ignore[attr-defined] + version_token = cache_manager.get_model_version_sync(model_name) + cache_key = _build_list_cache_key( + model_name=model_name, + version_token=version_token, + method="list_and_count", + filters=filters, + kwargs=kwargs, + order_by=resolved_order_by, + execution_options=resolved_execution_options, + uniquify=self._uniquify, + count_with_window_function=count_with_window_function, + ) + if cache_key is None: + return self._list_and_count_uncached_impl( + filters=filters, + auto_expunge=auto_expunge, + statement=statement, + count_with_window_function=count_with_window_function, order_by=order_by, - error_messages=error_messages, - bind_group=bind_group, - **kwargs, + error_messages=resolved_error_messages, + load=load, + execution_options=execution_options, + kwargs=kwargs, + uniquify=uniquify, ) - return self._list_and_count_window( - *filters, - auto_expunge=auto_expunge, - statement=statement, - load=load, - execution_options=execution_options, - error_messages=error_messages, - order_by=order_by, - bind_group=bind_group, - **kwargs, + + cached = cache_manager.get_list_and_count_sync(cache_key, self.model_type) + if cached is not None: + return cached + + return cache_manager.singleflight_sync( + cache_key, + partial( + self._list_and_count_cached_creator, + cache_key, + filters=filters, + auto_expunge=auto_expunge, + statement=statement, + count_with_window_function=count_with_window_function, + order_by=order_by, + error_messages=resolved_error_messages, + load=load, + execution_options=execution_options, + kwargs=kwargs, + uniquify=uniquify, + ), ) def _expunge(self, instance: "ModelT", auto_expunge: "Optional[bool]") -> None: @@ -2075,6 +2554,8 @@ def upsert( auto_refresh=auto_refresh, ) self._expunge(instance, auto_expunge=auto_expunge) + # Queue cache invalidation (processed on commit) + self._queue_cache_invalidation(self.get_id_attribute_value(instance)) return instance def upsert_many( @@ -2223,6 +2704,7 @@ def list( load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, + use_cache: bool = True, bind_group: Optional[str] = None, **kwargs: Any, ) -> list[ModelT]: @@ -2238,41 +2720,88 @@ def list( load: Set relationships to be loaded execution_options: Set default execution options uniquify: Optionally apply the ``unique()`` method to results before returning. - bind_group: Optional routing group to use for the operation. + use_cache: Whether to use the cache for this query. Defaults to ``True``. **kwargs: Instance attribute value filters. Returns: The list of instances, after filtering applied. """ self._uniquify = self._get_uniquify(uniquify) - error_messages = self._get_error_messages( + resolved_error_messages = self._get_error_messages( error_messages=error_messages, default_messages=self.error_messages, ) - with wrap_sqlalchemy_exception( - error_messages=error_messages, dialect_name=self._dialect.name, wrap_exceptions=self.wrap_exceptions + + resolved_auto_expunge = self.auto_expunge if auto_expunge is None else auto_expunge + resolved_execution_options = self._get_execution_options(execution_options) + resolved_order_by = order_by if order_by is not None else (self.order_by if self.order_by is not None else []) + + cache_manager = self._cache_manager + if not ( + use_cache + and bool(resolved_auto_expunge) + and cache_manager is not None + and statement is None + and load is None + and not self._default_loader_options ): - if bind_group: - execution_options = dict(execution_options) if execution_options else {} - execution_options["bind_group"] = bind_group - execution_options = self._get_execution_options(execution_options) - statement = self.statement if statement is None else statement - loader_options, loader_options_have_wildcard = self._get_loader_options(load) - statement = self._get_base_stmt( + return self._list_uncached_impl( + filters=filters, + auto_expunge=auto_expunge, statement=statement, - loader_options=loader_options, + order_by=order_by, + error_messages=resolved_error_messages, + load=load, execution_options=execution_options, + kwargs=kwargs, + uniquify=uniquify, ) - if order_by is None: - order_by = self.order_by if self.order_by is not None else [] - statement = self._apply_order_by(statement=statement, order_by=order_by) - statement = self._apply_filters(*filters, statement=statement) - statement = self._filter_select_by_kwargs(statement, kwargs) - result = self._execute(statement, uniquify=loader_options_have_wildcard) - instances = list(result.scalars()) - for instance in instances: - self._expunge(instance, auto_expunge=auto_expunge) - return cast("list[ModelT]", instances) + + model_name = cast("str", self.model_type.__tablename__) # type: ignore[attr-defined] + version_token = cache_manager.get_model_version_sync(model_name) + cache_key = _build_list_cache_key( + model_name=model_name, + version_token=version_token, + method="list", + filters=filters, + kwargs=kwargs, + order_by=resolved_order_by, + execution_options=resolved_execution_options, + uniquify=self._uniquify, + ) + if cache_key is None: + return self._list_uncached_impl( + filters=filters, + auto_expunge=auto_expunge, + statement=statement, + order_by=order_by, + error_messages=resolved_error_messages, + load=load, + execution_options=execution_options, + kwargs=kwargs, + uniquify=uniquify, + ) + + cached = cache_manager.get_list_sync(cache_key, self.model_type) + if cached is not None: + return cached + + return cache_manager.singleflight_sync( + cache_key, + partial( + self._list_cached_creator, + cache_key, + filters=filters, + auto_expunge=auto_expunge, + statement=statement, + order_by=order_by, + error_messages=resolved_error_messages, + load=load, + execution_options=execution_options, + kwargs=kwargs, + uniquify=uniquify, + ), + ) @classmethod def check_health(cls, session: Union[Session, scoped_session[Session]]) -> bool: diff --git a/advanced_alchemy/repository/memory/_async.py b/advanced_alchemy/repository/memory/_async.py index d183de455..37df5060a 100644 --- a/advanced_alchemy/repository/memory/_async.py +++ b/advanced_alchemy/repository/memory/_async.py @@ -456,6 +456,7 @@ async def get( execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, with_for_update: ForUpdateParameter = None, + use_cache: bool = True, bind_group: Optional[str] = None, ) -> ModelT: return self._find_or_raise_not_found(item_id) @@ -744,6 +745,7 @@ async def list_and_count( load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, + use_cache: bool = True, bind_group: Optional[str] = None, **kwargs: Any, ) -> tuple[list[ModelT], int]: @@ -753,6 +755,7 @@ async def list( self, *filters: Union[StatementFilter, ColumnElement[bool]], uniquify: Optional[bool] = None, + use_cache: bool = True, bind_group: Optional[str] = None, **kwargs: Any, ) -> list[ModelT]: diff --git a/advanced_alchemy/repository/memory/_sync.py b/advanced_alchemy/repository/memory/_sync.py index d82fa354b..579d73fd9 100644 --- a/advanced_alchemy/repository/memory/_sync.py +++ b/advanced_alchemy/repository/memory/_sync.py @@ -457,6 +457,7 @@ def get( execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, with_for_update: ForUpdateParameter = None, + use_cache: bool = True, bind_group: Optional[str] = None, ) -> ModelT: return self._find_or_raise_not_found(item_id) @@ -745,6 +746,7 @@ def list_and_count( load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, + use_cache: bool = True, bind_group: Optional[str] = None, **kwargs: Any, ) -> tuple[list[ModelT], int]: @@ -754,6 +756,7 @@ def list( self, *filters: Union[StatementFilter, ColumnElement[bool]], uniquify: Optional[bool] = None, + use_cache: bool = True, bind_group: Optional[str] = None, **kwargs: Any, ) -> list[ModelT]: diff --git a/advanced_alchemy/service/_async.py b/advanced_alchemy/service/_async.py index c8cf640df..5fc6b5828 100644 --- a/advanced_alchemy/service/_async.py +++ b/advanced_alchemy/service/_async.py @@ -514,6 +514,7 @@ async def list_and_count( load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, + use_cache: bool = True, bind_group: Optional[str] = None, **kwargs: Any, ) -> tuple[Sequence[ModelT], int]: @@ -530,7 +531,7 @@ async def list_and_count( load: Set relationships to be loaded execution_options: Set default execution options uniquify: Optionally apply the ``unique()`` method to results before returning. - bind_group: The bind group to use for the operation. + use_cache: Whether to use the repository cache for this query. **kwargs: Instance attribute value filters. Returns: @@ -548,7 +549,7 @@ async def list_and_count( load=load, execution_options=execution_options, uniquify=self._get_uniquify(uniquify), - bind_group=bind_group, + use_cache=use_cache, **kwargs, ), ) @@ -611,6 +612,7 @@ async def list( load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, + use_cache: bool = True, bind_group: Optional[str] = None, **kwargs: Any, ) -> Sequence[ModelT]: @@ -626,7 +628,7 @@ async def list( load: Set default relationships to be loaded execution_options: Set default execution options uniquify: Optionally apply the ``unique()`` method to results before returning. - bind_group: The bind group to use for the operation. + use_cache: Whether to use the repository cache for this query. **kwargs: Instance attribute value filters. Returns: @@ -643,7 +645,7 @@ async def list( load=load, execution_options=execution_options, uniquify=self._get_uniquify(uniquify), - bind_group=bind_group, + use_cache=use_cache, **kwargs, ), ) diff --git a/advanced_alchemy/service/_sync.py b/advanced_alchemy/service/_sync.py index 643fa9cd0..25ff487e6 100644 --- a/advanced_alchemy/service/_sync.py +++ b/advanced_alchemy/service/_sync.py @@ -513,6 +513,7 @@ def list_and_count( load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, + use_cache: bool = True, bind_group: Optional[str] = None, **kwargs: Any, ) -> tuple[Sequence[ModelT], int]: @@ -529,7 +530,7 @@ def list_and_count( load: Set relationships to be loaded execution_options: Set default execution options uniquify: Optionally apply the ``unique()`` method to results before returning. - bind_group: The bind group to use for the operation. + use_cache: Whether to use the repository cache for this query. **kwargs: Instance attribute value filters. Returns: @@ -547,7 +548,7 @@ def list_and_count( load=load, execution_options=execution_options, uniquify=self._get_uniquify(uniquify), - bind_group=bind_group, + use_cache=use_cache, **kwargs, ), ) @@ -610,6 +611,7 @@ def list( load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, + use_cache: bool = True, bind_group: Optional[str] = None, **kwargs: Any, ) -> Sequence[ModelT]: @@ -625,7 +627,7 @@ def list( load: Set default relationships to be loaded execution_options: Set default execution options uniquify: Optionally apply the ``unique()`` method to results before returning. - bind_group: The bind group to use for the operation. + use_cache: Whether to use the repository cache for this query. **kwargs: Instance attribute value filters. Returns: @@ -642,7 +644,7 @@ def list( load=load, execution_options=execution_options, uniquify=self._get_uniquify(uniquify), - bind_group=bind_group, + use_cache=use_cache, **kwargs, ), ) diff --git a/pyproject.toml b/pyproject.toml index c1f10a178..5afa760cb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -562,6 +562,16 @@ SQLAlchemyAsyncRepository = "SQLAlchemySyncRepository" SQLAlchemyAsyncRepositoryProtocol = "SQLAlchemySyncRepositoryProtocol" "SQLAlchemyAsyncSlugRepository" = "SQLAlchemySyncSlugRepository" SQLAlchemyAsyncSlugRepositoryProtocol = "SQLAlchemySyncSlugRepositoryProtocol" +"bump_model_version_async" = "bump_model_version_sync" +"get_entity_async" = "get_entity_sync" +"get_list_and_count_async" = "get_list_and_count_sync" +"get_list_async" = "get_list_sync" +"get_model_version_async" = "get_model_version_sync" +"invalidate_entity_async" = "invalidate_entity_sync" +"set_entity_async" = "set_entity_sync" +"set_list_and_count_async" = "set_list_and_count_sync" +"set_list_async" = "set_list_sync" +"singleflight_async" = "singleflight_sync" "async_scoped_session" = "scoped_session" "sqlalchemy.ext.asyncio.AsyncSession" = "sqlalchemy.orm.Session" "sqlalchemy.ext.asyncio.scoping.async_scoped_session" = "sqlalchemy.orm.scoping.scoped_session" diff --git a/tests/integration/test_cache_repository.py b/tests/integration/test_cache_repository.py new file mode 100644 index 000000000..605edb8d0 --- /dev/null +++ b/tests/integration/test_cache_repository.py @@ -0,0 +1,555 @@ +"""Integration tests for repository caching with dogpile.cache.""" + +from __future__ import annotations + +import asyncio +from collections.abc import Generator +from typing import TYPE_CHECKING, Any, Optional, cast + +import pytest +from sqlalchemy import Engine, String, event +from sqlalchemy.ext.asyncio import AsyncEngine +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column + +from advanced_alchemy.base import UUIDBase +from advanced_alchemy.cache import setup_cache_listeners +from advanced_alchemy.cache.config import CacheConfig +from advanced_alchemy.cache.manager import DOGPILE_CACHE_INSTALLED, CacheManager +from advanced_alchemy.repository import SQLAlchemyAsyncRepository, SQLAlchemySyncRepository + +if TYPE_CHECKING: + pass + +pytestmark = [ + pytest.mark.integration, + pytest.mark.xdist_group("cache"), +] + + +@pytest.fixture(scope="module", autouse=True) +def _setup_cache_listeners() -> Generator[None, None, None]: + """Set up global cache listeners for all tests in this module.""" + setup_cache_listeners() + yield + + +# Module-level cache for model and counter for unique names +_model_cache: dict[str, type] = {} +_class_counter = 0 + + +def get_cached_author_model(engine_dialect_name: str, worker_id: str) -> type[DeclarativeBase]: + """Create appropriate CachedAuthor model based on engine dialect.""" + global _class_counter + cache_key = f"cached_author_{worker_id}_{engine_dialect_name}" + + if cache_key not in _model_cache: + + class TestBase(DeclarativeBase): + pass + + _class_counter += 1 + unique_suffix = f"{_class_counter}_{worker_id}_{engine_dialect_name}" + + class_name = f"CachedAuthor_{unique_suffix}" + + CachedAuthor = type( + class_name, + (UUIDBase, TestBase), + { + "__tablename__": f"test_cached_authors_{worker_id}_{engine_dialect_name}", + "__mapper_args__": {"concrete": True}, + "__module__": __name__, + "name": mapped_column(String(length=100)), + "bio": mapped_column(String(length=500), nullable=True), + "__annotations__": {"name": Mapped[str], "bio": Mapped[Optional[str]]}, + }, + ) + + _model_cache[cache_key] = CachedAuthor + + return _model_cache[cache_key] + + +def get_worker_id(request: pytest.FixtureRequest) -> str: + """Get worker ID for pytest-xdist or 'master' for single process.""" + workerinput = getattr(request.config, "workerinput", None) + if isinstance(workerinput, dict): + return cast("str", workerinput.get("workerid", "master")) + return "master" + + +@pytest.fixture +def memory_cache_manager() -> CacheManager: + """Create a CacheManager with memory backend for testing.""" + config = CacheConfig( + backend="dogpile.cache.memory", + expiration_time=300, + key_prefix="test:", + ) + return CacheManager(config) + + +@pytest.fixture +def disabled_cache_manager() -> CacheManager: + """Create a CacheManager with caching disabled.""" + config = CacheConfig(enabled=False) + return CacheManager(config) + + +# Async repository tests + + +@pytest.mark.asyncio +@pytest.mark.aiosqlite +@pytest.mark.skipif(not DOGPILE_CACHE_INSTALLED, reason="dogpile.cache not installed") +async def test_async_repository_get_uses_cache( + aiosqlite_engine: AsyncEngine, + memory_cache_manager: CacheManager, + request: pytest.FixtureRequest, +) -> None: + """Test async repository get() uses cache on second call.""" + from sqlalchemy.ext.asyncio import AsyncSession as AS + from sqlalchemy.ext.asyncio import async_sessionmaker + + worker_id = get_worker_id(request) + CachedAuthor = get_cached_author_model("aiosqlite", worker_id) + + # Create tables + async with aiosqlite_engine.begin() as conn: + await conn.run_sync(CachedAuthor.metadata.create_all) + + try: + async_session_factory = async_sessionmaker(aiosqlite_engine, class_=AS, expire_on_commit=False) + async with async_session_factory() as session: + + class CachedAuthorRepository(SQLAlchemyAsyncRepository[Any]): + model_type = CachedAuthor + + repo = CachedAuthorRepository(session=session, cache_manager=memory_cache_manager, auto_expunge=True) + + # Create an author + author = CachedAuthor(name="John Doe", bio="Author bio") + await repo.add(author) + await session.commit() + + author_id = author.id + + # First get - should hit database and populate cache + author1 = await repo.get(author_id) + assert author1.name == "John Doe" + + # Verify cache was populated + table_name = CachedAuthor.__tablename__ + cached = memory_cache_manager.get_entity_sync(table_name, author_id, CachedAuthor) + assert cached is not None + assert cached.name == "John Doe" + + # Get again - should use cache + author2 = await repo.get(author_id) + assert author2.name == "John Doe" + + finally: + async with aiosqlite_engine.begin() as conn: + await conn.run_sync(CachedAuthor.metadata.drop_all) + + +@pytest.mark.asyncio +@pytest.mark.aiosqlite +@pytest.mark.skipif(not DOGPILE_CACHE_INSTALLED, reason="dogpile.cache not installed") +async def test_async_repository_get_use_cache_false_bypasses_cache( + aiosqlite_engine: AsyncEngine, + memory_cache_manager: CacheManager, + request: pytest.FixtureRequest, +) -> None: + """Test async repository get() with use_cache=False bypasses cache.""" + from sqlalchemy.ext.asyncio import AsyncSession as AS + from sqlalchemy.ext.asyncio import async_sessionmaker + + worker_id = get_worker_id(request) + CachedAuthor = get_cached_author_model("aiosqlite_bypass", worker_id) + + async with aiosqlite_engine.begin() as conn: + await conn.run_sync(CachedAuthor.metadata.create_all) + + try: + async_session_factory = async_sessionmaker(aiosqlite_engine, class_=AS, expire_on_commit=False) + async with async_session_factory() as session: + + class CachedAuthorRepository(SQLAlchemyAsyncRepository[Any]): + model_type = CachedAuthor + + repo = CachedAuthorRepository(session=session, cache_manager=memory_cache_manager, auto_expunge=True) + + # Create an author + author = CachedAuthor(name="Jane Doe") + await repo.add(author) + await session.commit() + + author_id = author.id + + # Get with cache disabled + author1 = await repo.get(author_id, use_cache=False) + assert author1.name == "Jane Doe" + + # Cache should be empty since we used use_cache=False + table_name = CachedAuthor.__tablename__ + cached = memory_cache_manager.get_entity_sync(table_name, author_id, CachedAuthor) + assert cached is None + + finally: + async with aiosqlite_engine.begin() as conn: + await conn.run_sync(CachedAuthor.metadata.drop_all) + + +@pytest.mark.asyncio +@pytest.mark.aiosqlite +async def test_async_repository_without_cache_manager_works( + aiosqlite_engine: AsyncEngine, + request: pytest.FixtureRequest, +) -> None: + """Test async repository works without cache_manager (cache disabled).""" + from sqlalchemy.ext.asyncio import AsyncSession as AS + from sqlalchemy.ext.asyncio import async_sessionmaker + + worker_id = get_worker_id(request) + CachedAuthor = get_cached_author_model("aiosqlite_nocache", worker_id) + + async with aiosqlite_engine.begin() as conn: + await conn.run_sync(CachedAuthor.metadata.create_all) + + try: + async_session_factory = async_sessionmaker(aiosqlite_engine, class_=AS, expire_on_commit=False) + async with async_session_factory() as session: + + class CachedAuthorRepository(SQLAlchemyAsyncRepository[Any]): + model_type = CachedAuthor + + repo = CachedAuthorRepository(session=session) # No cache_manager + + # Should work normally + author = CachedAuthor(name="No Cache") + await repo.add(author) + await session.commit() + + retrieved = await repo.get(author.id) + assert retrieved.name == "No Cache" + + finally: + async with aiosqlite_engine.begin() as conn: + await conn.run_sync(CachedAuthor.metadata.drop_all) + + +@pytest.mark.asyncio +@pytest.mark.aiosqlite +async def test_async_repository_cache_disabled_config( + aiosqlite_engine: AsyncEngine, + disabled_cache_manager: CacheManager, + request: pytest.FixtureRequest, +) -> None: + """Test async repository with disabled cache config.""" + from sqlalchemy.ext.asyncio import AsyncSession as AS + from sqlalchemy.ext.asyncio import async_sessionmaker + + worker_id = get_worker_id(request) + CachedAuthor = get_cached_author_model("aiosqlite_disabled", worker_id) + + async with aiosqlite_engine.begin() as conn: + await conn.run_sync(CachedAuthor.metadata.create_all) + + try: + async_session_factory = async_sessionmaker(aiosqlite_engine, class_=AS, expire_on_commit=False) + async with async_session_factory() as session: + + class CachedAuthorRepository(SQLAlchemyAsyncRepository[Any]): + model_type = CachedAuthor + + repo = CachedAuthorRepository(session=session, cache_manager=disabled_cache_manager, auto_expunge=True) + + # Should work but not cache anything + author = CachedAuthor(name="Test") + await repo.add(author) + await session.commit() + + retrieved = await repo.get(author.id) + assert retrieved.name == "Test" + + finally: + async with aiosqlite_engine.begin() as conn: + await conn.run_sync(CachedAuthor.metadata.drop_all) + + +@pytest.mark.asyncio +@pytest.mark.aiosqlite +@pytest.mark.skipif(not DOGPILE_CACHE_INSTALLED, reason="dogpile.cache not installed") +async def test_async_repository_list_uses_cache_and_invalidates_on_commit( + aiosqlite_engine: AsyncEngine, + memory_cache_manager: CacheManager, + request: pytest.FixtureRequest, +) -> None: + """Test async repository list() caching and version-token invalidation.""" + from sqlalchemy.ext.asyncio import AsyncSession as AS + from sqlalchemy.ext.asyncio import async_sessionmaker + + worker_id = get_worker_id(request) + CachedAuthor = get_cached_author_model("aiosqlite_list", worker_id) + + async with aiosqlite_engine.begin() as conn: + await conn.run_sync(CachedAuthor.metadata.create_all) + + query_count = 0 + + def before_cursor_execute(_conn: object, _cursor: object, statement: str, *_: object) -> None: + nonlocal query_count + if statement.lstrip().upper().startswith("SELECT"): + query_count += 1 + + event.listen(aiosqlite_engine.sync_engine, "before_cursor_execute", before_cursor_execute) + + try: + async_session_factory = async_sessionmaker(aiosqlite_engine, class_=AS, expire_on_commit=False) + async with async_session_factory() as session: + + class CachedAuthorRepository(SQLAlchemyAsyncRepository[Any]): + model_type = CachedAuthor + + repo = CachedAuthorRepository(session=session, cache_manager=memory_cache_manager, auto_expunge=True) + + author = CachedAuthor(name="List Test", bio="Bio") + await repo.add(author) + await session.commit() + + # Ensure we start from a known version token + model_name = CachedAuthor.__tablename__ + version_before = memory_cache_manager.get_model_version_sync(model_name) + + query_count = 0 + authors_1 = await repo.list() + assert len(authors_1) == 1 + assert query_count > 0 + + query_count = 0 + authors_2 = await repo.list() + assert len(authors_2) == 1 + assert query_count == 0 + + # Mutate + commit should bump model version token (invalidating list caches) + author = await repo.get(author.id, use_cache=False) + author.bio = "Updated" + await repo.update(author) + await session.commit() + + # Wait for eventual async invalidation tasks to complete + import advanced_alchemy._listeners as listeners + + if listeners._active_cache_operations: + await asyncio.gather(*list(listeners._active_cache_operations)) + + version_after = memory_cache_manager.get_model_version_sync(model_name) + assert version_after != version_before + + query_count = 0 + authors_3 = await repo.list() + assert len(authors_3) == 1 + assert query_count > 0 + + finally: + event.remove(aiosqlite_engine.sync_engine, "before_cursor_execute", before_cursor_execute) + async with aiosqlite_engine.begin() as conn: + await conn.run_sync(CachedAuthor.metadata.drop_all) + + +@pytest.mark.asyncio +@pytest.mark.aiosqlite +@pytest.mark.skipif(not DOGPILE_CACHE_INSTALLED, reason="dogpile.cache not installed") +async def test_async_repository_list_and_count_uses_cache( + aiosqlite_engine: AsyncEngine, + memory_cache_manager: CacheManager, + request: pytest.FixtureRequest, +) -> None: + """Test async repository list_and_count() caching.""" + from sqlalchemy.ext.asyncio import AsyncSession as AS + from sqlalchemy.ext.asyncio import async_sessionmaker + + worker_id = get_worker_id(request) + CachedAuthor = get_cached_author_model("aiosqlite_list_and_count", worker_id) + + async with aiosqlite_engine.begin() as conn: + await conn.run_sync(CachedAuthor.metadata.create_all) + + query_count = 0 + + def before_cursor_execute(_conn: object, _cursor: object, statement: str, *_: object) -> None: + nonlocal query_count + if statement.lstrip().upper().startswith("SELECT"): + query_count += 1 + + event.listen(aiosqlite_engine.sync_engine, "before_cursor_execute", before_cursor_execute) + + try: + async_session_factory = async_sessionmaker(aiosqlite_engine, class_=AS, expire_on_commit=False) + async with async_session_factory() as session: + + class CachedAuthorRepository(SQLAlchemyAsyncRepository[Any]): + model_type = CachedAuthor + + repo = CachedAuthorRepository(session=session, cache_manager=memory_cache_manager, auto_expunge=True) + + await repo.add(CachedAuthor(name="A1")) + await repo.add(CachedAuthor(name="A2")) + await session.commit() + + query_count = 0 + items_1, count_1 = await repo.list_and_count() + assert count_1 == 2 + assert len(items_1) == 2 + assert query_count > 0 + + query_count = 0 + items_2, count_2 = await repo.list_and_count() + assert count_2 == 2 + assert len(items_2) == 2 + assert query_count == 0 + + finally: + event.remove(aiosqlite_engine.sync_engine, "before_cursor_execute", before_cursor_execute) + async with aiosqlite_engine.begin() as conn: + await conn.run_sync(CachedAuthor.metadata.drop_all) + + +@pytest.mark.asyncio +@pytest.mark.aiosqlite +@pytest.mark.skipif(not DOGPILE_CACHE_INSTALLED, reason="dogpile.cache not installed") +async def test_async_repository_get_singleflight_coalesces_concurrent_misses( + aiosqlite_engine: AsyncEngine, + memory_cache_manager: CacheManager, + request: pytest.FixtureRequest, +) -> None: + """Test per-process async singleflight reduces stampedes on cache miss.""" + from sqlalchemy.ext.asyncio import AsyncSession as AS + from sqlalchemy.ext.asyncio import async_sessionmaker + + worker_id = get_worker_id(request) + CachedAuthor = get_cached_author_model("aiosqlite_singleflight", worker_id) + + async with aiosqlite_engine.begin() as conn: + await conn.run_sync(CachedAuthor.metadata.create_all) + + query_count = 0 + + def before_cursor_execute(_conn: object, _cursor: object, statement: str, *_: object) -> None: + nonlocal query_count + if statement.lstrip().upper().startswith("SELECT"): + query_count += 1 + + event.listen(aiosqlite_engine.sync_engine, "before_cursor_execute", before_cursor_execute) + + try: + async_session_factory = async_sessionmaker(aiosqlite_engine, class_=AS, expire_on_commit=False) + async with async_session_factory() as session: + + class CachedAuthorRepository(SQLAlchemyAsyncRepository[Any]): + model_type = CachedAuthor + + repo = CachedAuthorRepository(session=session, cache_manager=memory_cache_manager, auto_expunge=True) + + author = CachedAuthor(name="SF") + await repo.add(author) + await session.commit() + + author_id = author.id + model_name = CachedAuthor.__tablename__ + + # Force cache miss for this entity + memory_cache_manager.invalidate_entity_sync(model_name, author_id) + + query_count = 0 + results = await asyncio.gather(*[repo.get(author_id) for _ in range(10)]) + assert all(r.id == author_id for r in results) + assert query_count == 1 + + finally: + event.remove(aiosqlite_engine.sync_engine, "before_cursor_execute", before_cursor_execute) + async with aiosqlite_engine.begin() as conn: + await conn.run_sync(CachedAuthor.metadata.drop_all) + + +# Sync repository tests + + +@pytest.mark.sqlite +@pytest.mark.skipif(not DOGPILE_CACHE_INSTALLED, reason="dogpile.cache not installed") +def test_sync_repository_get_uses_cache( + sqlite_engine: Engine, + memory_cache_manager: CacheManager, + request: pytest.FixtureRequest, +) -> None: + """Test sync repository get() uses cache on second call.""" + from sqlalchemy.orm import sessionmaker + + worker_id = get_worker_id(request) + CachedAuthor = get_cached_author_model("sqlite", worker_id) + + CachedAuthor.metadata.create_all(sqlite_engine) + + try: + session_factory = sessionmaker(sqlite_engine) + with session_factory() as session: + + class CachedAuthorRepository(SQLAlchemySyncRepository[Any]): + model_type = CachedAuthor + + repo = CachedAuthorRepository(session=session, cache_manager=memory_cache_manager, auto_expunge=True) + + # Create an author + author = CachedAuthor(name="John Doe", bio="Author bio") + repo.add(author) + session.commit() + + author_id = author.id + + # First get - should hit database and populate cache + author1 = repo.get(author_id) + assert author1.name == "John Doe" + + # Verify cache was populated + table_name = CachedAuthor.__tablename__ + cached = memory_cache_manager.get_entity_sync(table_name, author_id, CachedAuthor) + assert cached is not None + assert cached.name == "John Doe" + + finally: + CachedAuthor.metadata.drop_all(sqlite_engine) + + +@pytest.mark.sqlite +def test_sync_repository_without_cache_manager_works( + sqlite_engine: Engine, + request: pytest.FixtureRequest, +) -> None: + """Test sync repository works without cache_manager.""" + from sqlalchemy.orm import sessionmaker + + worker_id = get_worker_id(request) + CachedAuthor = get_cached_author_model("sqlite_nocache", worker_id) + + CachedAuthor.metadata.create_all(sqlite_engine) + + try: + session_factory = sessionmaker(sqlite_engine) + with session_factory() as session: + + class CachedAuthorRepository(SQLAlchemySyncRepository[Any]): + model_type = CachedAuthor + + repo = CachedAuthorRepository(session=session) # No cache_manager + + author = CachedAuthor(name="No Cache Sync") + repo.add(author) + session.commit() + + retrieved = repo.get(author.id) + assert retrieved.name == "No Cache Sync" + + finally: + CachedAuthor.metadata.drop_all(sqlite_engine) diff --git a/tests/unit/test_cache/test_cache_config.py b/tests/unit/test_cache/test_cache_config.py new file mode 100644 index 000000000..4c56f7d0e --- /dev/null +++ b/tests/unit/test_cache/test_cache_config.py @@ -0,0 +1,87 @@ +"""Unit tests for CacheConfig dataclass.""" + +from __future__ import annotations + +from advanced_alchemy.cache.config import CacheConfig + + +def test_cache_config_defaults() -> None: + """Test CacheConfig has sensible defaults.""" + config = CacheConfig() + + assert config.backend == "dogpile.cache.null" + assert config.expiration_time == 3600 + assert config.arguments == {} + assert config.key_prefix == "aa:" + assert config.enabled is True + assert config.serializer is None + assert config.deserializer is None + assert config.region_factory is None + + +def test_cache_config_custom_backend() -> None: + """Test CacheConfig with custom backend.""" + config = CacheConfig( + backend="dogpile.cache.memory", + expiration_time=300, + ) + + assert config.backend == "dogpile.cache.memory" + assert config.expiration_time == 300 + + +def test_cache_config_redis_arguments() -> None: + """Test CacheConfig with Redis-specific arguments.""" + config = CacheConfig( + backend="dogpile.cache.redis", + expiration_time=600, + arguments={ + "host": "localhost", + "port": 6379, + "db": 0, + }, + ) + + assert config.backend == "dogpile.cache.redis" + assert config.arguments["host"] == "localhost" + assert config.arguments["port"] == 6379 + assert config.arguments["db"] == 0 + + +def test_cache_config_disabled() -> None: + """Test CacheConfig can be disabled.""" + config = CacheConfig(enabled=False) + + assert config.enabled is False + + +def test_cache_config_custom_key_prefix() -> None: + """Test CacheConfig with custom key prefix.""" + config = CacheConfig(key_prefix="myapp:") + + assert config.key_prefix == "myapp:" + + +def test_cache_config_custom_serializers() -> None: + """Test CacheConfig with custom serializer/deserializer.""" + + def custom_serializer(obj: object) -> bytes: + return b"serialized" + + def custom_deserializer(data: bytes, model_class: type) -> object: + return model_class() + + config = CacheConfig( + serializer=custom_serializer, + deserializer=custom_deserializer, + ) + + assert config.serializer is custom_serializer + assert config.deserializer is custom_deserializer + + +def test_cache_config_no_expiration() -> None: + """Test CacheConfig with no expiration (set to -1).""" + config = CacheConfig(expiration_time=-1) + + assert config.expiration_time == -1 diff --git a/tests/unit/test_cache/test_cache_manager.py b/tests/unit/test_cache/test_cache_manager.py new file mode 100644 index 000000000..3e823270d --- /dev/null +++ b/tests/unit/test_cache/test_cache_manager.py @@ -0,0 +1,429 @@ +"""Unit tests for CacheManager.""" + +from __future__ import annotations + +import asyncio +import time +from typing import Any +from unittest.mock import MagicMock + +import pytest + +from advanced_alchemy.cache.config import CacheConfig +from advanced_alchemy.cache.manager import DOGPILE_CACHE_INSTALLED, CacheManager + + +@pytest.fixture +def memory_config() -> CacheConfig: + """Create a memory cache configuration.""" + return CacheConfig(backend="dogpile.cache.memory", expiration_time=300) + + +@pytest.fixture +def disabled_config() -> CacheConfig: + """Create a disabled cache configuration.""" + return CacheConfig(enabled=False) + + +@pytest.mark.skipif(not DOGPILE_CACHE_INSTALLED, reason="dogpile.cache not installed") +def test_cache_manager_lazy_initialization(memory_config: CacheConfig) -> None: + """Test CacheManager lazy initializes the region.""" + manager = CacheManager(memory_config) + + # Region should be None initially + assert manager._region is None + + # Accessing region should initialize it + region = manager.region + assert region is not None + assert manager._region is region + + +@pytest.mark.skipif(not DOGPILE_CACHE_INSTALLED, reason="dogpile.cache not installed") +def test_cache_manager_creates_region_with_config(memory_config: CacheConfig) -> None: + """Test CacheManager creates region with correct configuration.""" + manager = CacheManager(memory_config) + + region = manager.region + + # Verify region is configured (dogpile.cache doesn't expose config easily, + # but we can test it works) + assert region is not None + + +def test_cache_manager_disabled_returns_null_region(disabled_config: CacheConfig) -> None: + """Test CacheManager returns NullRegion when disabled.""" + manager = CacheManager(disabled_config) + + region = manager.region + + # Should be NullRegion + from advanced_alchemy.cache._null import NullRegion + + assert isinstance(region, NullRegion) + + +@pytest.mark.skipif(DOGPILE_CACHE_INSTALLED, reason="Test requires dogpile.cache NOT installed") +def test_cache_manager_without_dogpile_returns_null_region() -> None: + """Test CacheManager returns NullRegion when dogpile.cache not installed.""" + config = CacheConfig(backend="dogpile.cache.memory") + manager = CacheManager(config) + + region = manager.region + + from advanced_alchemy.cache._null import NullRegion + + assert isinstance(region, NullRegion) + + +@pytest.mark.skipif(not DOGPILE_CACHE_INSTALLED, reason="dogpile.cache not installed") +def test_cache_manager_get_or_create_caches_value(memory_config: CacheConfig) -> None: + """Test get_or_create caches the created value.""" + manager = CacheManager(memory_config) + call_count = 0 + + def creator() -> str: + nonlocal call_count + call_count += 1 + return "test_value" + + # First call should invoke creator + result1 = manager.get_or_create_sync("test_key", creator) + assert result1 == "test_value" + assert call_count == 1 + + # Second call should use cached value + result2 = manager.get_or_create_sync("test_key", creator) + assert result2 == "test_value" + assert call_count == 1 # Creator not called again + + +@pytest.mark.skipif(not DOGPILE_CACHE_INSTALLED, reason="dogpile.cache not installed") +def test_cache_manager_get_or_create_with_custom_expiration(memory_config: CacheConfig) -> None: + """Test get_or_create with custom expiration time.""" + manager = CacheManager(memory_config) + + result = manager.get_or_create_sync("key", lambda: "value", expiration_time=60) + + assert result == "value" + + +def test_cache_manager_get_or_create_disabled_always_calls_creator(disabled_config: CacheConfig) -> None: + """Test get_or_create bypasses cache when disabled.""" + manager = CacheManager(disabled_config) + call_count = 0 + + def creator() -> str: + nonlocal call_count + call_count += 1 + return "value" + + # Both calls should invoke creator + result1 = manager.get_or_create_sync("key", creator) + result2 = manager.get_or_create_sync("key", creator) + + assert result1 == "value" + assert result2 == "value" + assert call_count == 2 + + +@pytest.mark.skipif(not DOGPILE_CACHE_INSTALLED, reason="dogpile.cache not installed") +def test_cache_manager_get_set_delete(memory_config: CacheConfig) -> None: + """Test get, set, and delete operations.""" + from advanced_alchemy.cache.manager import DOGPILE_NO_VALUE + + manager = CacheManager(memory_config) + + # Initially empty + result_initial = manager.get_sync("test_key") + assert result_initial is DOGPILE_NO_VALUE or result_initial is None + + # Set value + manager.set_sync("test_key", "test_value") + + # Get value + result = manager.get_sync("test_key") + assert result == "test_value" + + # Delete value + manager.delete_sync("test_key") + + # Should be empty again + result_after_delete = manager.get_sync("test_key") + assert result_after_delete is DOGPILE_NO_VALUE or result_after_delete is None + + +def test_cache_manager_get_disabled_returns_no_value(disabled_config: CacheConfig) -> None: + """Test get returns NO_VALUE when disabled.""" + manager = CacheManager(disabled_config) + + result = manager.get_sync("any_key") + + # Should return dogpile's NO_VALUE + from advanced_alchemy.cache.manager import DOGPILE_NO_VALUE + + assert result is DOGPILE_NO_VALUE + + +def test_cache_manager_set_disabled_is_noop(disabled_config: CacheConfig) -> None: + """Test set does nothing when disabled.""" + manager = CacheManager(disabled_config) + + # Should not raise + manager.set_sync("key", "value") + + +@pytest.mark.skipif(not DOGPILE_CACHE_INSTALLED, reason="dogpile.cache not installed") +def test_cache_manager_make_key_adds_prefix(memory_config: CacheConfig) -> None: + """Test _make_key adds the configured prefix.""" + manager = CacheManager(memory_config) + + key = manager._make_key("test_key") + + assert key == "aa:test_key" + + +@pytest.mark.skipif(not DOGPILE_CACHE_INSTALLED, reason="dogpile.cache not installed") +def test_cache_manager_get_entity(memory_config: CacheConfig) -> None: + """Test get_entity retrieves and deserializes cached entity.""" + from advanced_alchemy._serialization import encode_json + + manager = CacheManager(memory_config) + + # Manually cache a serialized entity (using JSON directly to avoid SQLAlchemy complexity) + fake_entity_data = { + "__aa_model__": "TestModel", + "__aa_table__": "test_model", + "id": "12345678-1234-5678-1234-567812345678", + "name": "Test Entity", + } + serialized = encode_json(fake_entity_data).encode("utf-8") + manager.set_sync("test_model:get:1", serialized) + + # Note: This test is simplified - we skip deserialization test because it requires + # a real SQLAlchemy model. The integration tests cover full serialization/deserialization. + + +@pytest.mark.skipif(not DOGPILE_CACHE_INSTALLED, reason="dogpile.cache not installed") +def test_cache_manager_get_entity_not_found_returns_none(memory_config: CacheConfig) -> None: + """Test get_entity returns None when entity not in cache.""" + + manager = CacheManager(memory_config) + + # Use a mock class to avoid SQLAlchemy model creation complexity + MockModel = MagicMock() + + cached = manager.get_entity_sync("test_model", 999, MockModel) + + assert cached is None + + +@pytest.mark.skipif(not DOGPILE_CACHE_INSTALLED, reason="dogpile.cache not installed") +def test_cache_manager_get_entity_deserialization_error_returns_none(memory_config: CacheConfig) -> None: + """Test get_entity returns None and deletes corrupted cache on deserialization error.""" + + from advanced_alchemy.cache.manager import DOGPILE_NO_VALUE + + MockModel = MagicMock() + + manager = CacheManager(memory_config) + + # Put corrupted data in cache + manager.set_sync("test_model:get:1", b"corrupted_data") + + # Should return None and log error + cached = manager.get_entity_sync("test_model", 1, MockModel) + + assert cached is None + + # Corrupted entry should be deleted + result = manager.get_sync("test_model:get:1") + assert result is DOGPILE_NO_VALUE or result is None + + +@pytest.mark.skipif(not DOGPILE_CACHE_INSTALLED, reason="dogpile.cache not installed") +def test_cache_manager_set_entity_serialization_succeeds() -> None: + """Test set_entity can cache data (serialization tested in integration).""" + # Note: Full serialization test requires real SQLAlchemy models in a session. + # This is covered by integration tests. Here we just test the basic flow works. + pass + + +@pytest.mark.skipif(not DOGPILE_CACHE_INSTALLED, reason="dogpile.cache not installed") +def test_cache_manager_set_entity_serialization_error_logs_and_continues(memory_config: CacheConfig) -> None: + """Test set_entity logs error and continues on serialization failure.""" + + class UnserializableModel: + """Model that cannot be serialized.""" + + def __init__(self) -> None: + self.data = lambda: None # Functions can't be serialized + + manager = CacheManager(memory_config) + + # Should not raise, just log error + manager.set_entity_sync("test_model", 1, UnserializableModel()) + + +@pytest.mark.skipif(not DOGPILE_CACHE_INSTALLED, reason="dogpile.cache not installed") +def test_cache_manager_invalidate_entity(memory_config: CacheConfig) -> None: + """Test invalidate_entity removes entity from cache.""" + from advanced_alchemy.cache.manager import DOGPILE_NO_VALUE + + manager = CacheManager(memory_config) + + # Set a value + manager.set_sync("users:get:1", b"test_data") + + # Invalidate + manager.invalidate_entity_sync("users", 1) + + # Should be gone + result = manager.get_sync("users:get:1") + assert result is DOGPILE_NO_VALUE or result is None + + +@pytest.mark.skipif(not DOGPILE_CACHE_INSTALLED, reason="dogpile.cache not installed") +def test_cache_manager_bump_model_version(memory_config: CacheConfig) -> None: + """Test bump_model_version changes version token.""" + manager = CacheManager(memory_config) + + # Initial version should be 0 + assert manager.get_model_version_sync("users") == "0" + + # Bump version + version1 = manager.bump_model_version_sync("users") + assert version1 != "0" + assert manager.get_model_version_sync("users") == version1 + + # Bump again + version2 = manager.bump_model_version_sync("users") + assert version2 != version1 + assert manager.get_model_version_sync("users") == version2 + + +@pytest.mark.skipif(not DOGPILE_CACHE_INSTALLED, reason="dogpile.cache not installed") +def test_cache_manager_get_model_version_from_cache(memory_config: CacheConfig) -> None: + """Test get_model_version retrieves version from distributed cache.""" + manager = CacheManager(memory_config) + + # Manually set version in cache + manager.set_sync("users:version", "token") + + # Should retrieve from cache + version = manager.get_model_version_sync("users") + assert version == "token" + + +@pytest.mark.skipif(not DOGPILE_CACHE_INSTALLED, reason="dogpile.cache not installed") +def test_cache_manager_invalidate_all(memory_config: CacheConfig) -> None: + """Test invalidate_all clears entire cache.""" + from advanced_alchemy.cache.manager import DOGPILE_NO_VALUE + + manager = CacheManager(memory_config) + + # Set some values + manager.set_sync("key1", "value1") + manager.set_sync("key2", "value2") + manager._model_versions["users"] = "token" + + # Invalidate all + manager.invalidate_all_sync() + + # All should be cleared + result1 = manager.get_sync("key1") + result2 = manager.get_sync("key2") + assert result1 is DOGPILE_NO_VALUE or result1 is None + assert result2 is DOGPILE_NO_VALUE or result2 is None + assert manager._model_versions == {} + + +@pytest.mark.skipif(not DOGPILE_CACHE_INSTALLED, reason="dogpile.cache not installed") +def test_cache_manager_custom_serializers(memory_config: CacheConfig) -> None: + """Test CacheManager with custom serializer/deserializer.""" + + def custom_serializer(obj: Any) -> bytes: + return b"custom" + + def custom_deserializer(data: bytes, model_class: type) -> Any: + return "deserialized" + + memory_config.serializer = custom_serializer + memory_config.deserializer = custom_deserializer + + manager = CacheManager(memory_config) + + # set_entity should use custom serializer + manager.set_entity_sync("test", 1, {"data": "test"}) + + # get_entity should use custom deserializer + result = manager.get_entity_sync("test", 1, str) + assert result == "deserialized" + + +def test_cache_manager_handles_region_creation_failure() -> None: + """Test CacheManager handles region creation failure gracefully.""" + config = CacheConfig(backend="invalid.backend.that.does.not.exist") + + manager = CacheManager(config) + + # Should return NullRegion on failure + region = manager.region + + from advanced_alchemy.cache._null import NullRegion + + assert isinstance(region, NullRegion) + + +@pytest.mark.asyncio +async def test_cache_manager_get_async_does_not_block_event_loop() -> None: + """Ensure cache I/O is offloaded and doesn't block the loop.""" + + class SlowRegion: + def get(self, key: str, expiration_time: int | None = None) -> Any: + time.sleep(0.2) + return "value" + + def set(self, key: str, value: Any) -> None: + return + + def delete(self, key: str) -> None: + return + + def invalidate(self) -> None: + return + + config = CacheConfig(region_factory=lambda _cfg: SlowRegion()) + manager = CacheManager(config) + + ticks = 0 + + async def ticker() -> None: + nonlocal ticks + for _ in range(5): + await asyncio.sleep(0.05) + ticks += 1 + + result, _ = await asyncio.gather(manager.get_async("key"), ticker()) + + assert result == "value" + assert ticks >= 3 + + +@pytest.mark.asyncio +async def test_cache_manager_singleflight_async_coalesces() -> None: + """Ensure async singleflight invokes creator only once per key.""" + manager = CacheManager(CacheConfig(backend="dogpile.cache.null")) + + call_count = 0 + + async def creator() -> str: + nonlocal call_count + call_count += 1 + await asyncio.sleep(0.05) + return "ok" + + results = await asyncio.gather(*[manager.singleflight_async("k", creator) for _ in range(25)]) + + assert call_count == 1 + assert results == ["ok"] * 25 diff --git a/tests/unit/test_cache/test_cache_serializers.py b/tests/unit/test_cache/test_cache_serializers.py new file mode 100644 index 000000000..af1f1544d --- /dev/null +++ b/tests/unit/test_cache/test_cache_serializers.py @@ -0,0 +1,217 @@ +"""Unit tests for cache serialization utilities.""" + +from __future__ import annotations + +import datetime +from decimal import Decimal +from uuid import UUID + +import pytest + +from advanced_alchemy._serialization import decode_json, encode_json +from advanced_alchemy.cache.serializers import _decode_special_types, _json_decoder, _json_encoder + + +def test_json_encoder_datetime() -> None: + """Test encoding datetime objects.""" + dt = datetime.datetime(2025, 12, 14, 10, 30, 0) + + result = _json_encoder(dt) + + assert result == {"__type__": "datetime", "value": "2025-12-14T10:30:00"} + + +def test_json_encoder_date() -> None: + """Test encoding date objects.""" + d = datetime.date(2025, 12, 14) + + result = _json_encoder(d) + + assert result == {"__type__": "date", "value": "2025-12-14"} + + +def test_json_encoder_time() -> None: + """Test encoding time objects.""" + t = datetime.time(10, 30, 45) + + result = _json_encoder(t) + + assert result == {"__type__": "time", "value": "10:30:45"} + + +def test_json_encoder_timedelta() -> None: + """Test encoding timedelta objects.""" + td = datetime.timedelta(hours=2, minutes=30) + + result = _json_encoder(td) + + assert result == {"__type__": "timedelta", "value": 9000.0} + + +def test_json_encoder_decimal() -> None: + """Test encoding Decimal objects.""" + dec = Decimal("123.45") + + result = _json_encoder(dec) + + assert result == {"__type__": "decimal", "value": "123.45"} + + +def test_json_encoder_bytes() -> None: + """Test encoding bytes objects.""" + b = b"\x01\x02\xff" + + result = _json_encoder(b) + + assert result == {"__type__": "bytes", "value": "0102ff"} + + +def test_json_encoder_uuid() -> None: + """Test encoding UUID objects.""" + u = UUID("12345678-1234-5678-1234-567812345678") + + result = _json_encoder(u) + + assert result == {"__type__": "uuid", "value": "12345678-1234-5678-1234-567812345678"} + + +def test_json_encoder_set() -> None: + """Test encoding set objects.""" + s = {1, 2, 3} + + result = _json_encoder(s) + + assert result["__type__"] == "set" + assert set(result["value"]) == {1, 2, 3} + + +def test_json_encoder_unsupported_type() -> None: + """Test encoding unsupported type raises TypeError.""" + with pytest.raises(TypeError, match=r"Object of type.*is not JSON serializable"): + _json_encoder(lambda: None) + + +def test_json_decoder_datetime() -> None: + """Test decoding datetime objects.""" + data = {"__type__": "datetime", "value": "2025-12-14T10:30:00"} + + result = _json_decoder(data) + + assert isinstance(result, datetime.datetime) + assert result == datetime.datetime(2025, 12, 14, 10, 30, 0) + + +def test_json_decoder_date() -> None: + """Test decoding date objects.""" + data = {"__type__": "date", "value": "2025-12-14"} + + result = _json_decoder(data) + + assert isinstance(result, datetime.date) + assert result == datetime.date(2025, 12, 14) + + +def test_json_decoder_time() -> None: + """Test decoding time objects.""" + data = {"__type__": "time", "value": "10:30:45"} + + result = _json_decoder(data) + + assert isinstance(result, datetime.time) + assert result == datetime.time(10, 30, 45) + + +def test_json_decoder_timedelta() -> None: + """Test decoding timedelta objects.""" + data = {"__type__": "timedelta", "value": 9000.0} + + result = _json_decoder(data) + + assert isinstance(result, datetime.timedelta) + assert result == datetime.timedelta(hours=2, minutes=30) + + +def test_json_decoder_decimal() -> None: + """Test decoding Decimal objects.""" + data = {"__type__": "decimal", "value": "123.45"} + + result = _json_decoder(data) + + assert isinstance(result, Decimal) + assert result == Decimal("123.45") + + +def test_json_decoder_bytes() -> None: + """Test decoding bytes objects.""" + data = {"__type__": "bytes", "value": "0102ff"} + + result = _json_decoder(data) + + assert isinstance(result, bytes) + assert result == b"\x01\x02\xff" + + +def test_json_decoder_uuid() -> None: + """Test decoding UUID objects.""" + data = {"__type__": "uuid", "value": "12345678-1234-5678-1234-567812345678"} + + result = _json_decoder(data) + + assert isinstance(result, UUID) + assert result == UUID("12345678-1234-5678-1234-567812345678") + + +def test_json_decoder_set() -> None: + """Test decoding set objects.""" + data = {"__type__": "set", "value": [1, 2, 3]} + + result = _json_decoder(data) + + assert isinstance(result, set) + assert result == {1, 2, 3} + + +def test_json_decoder_non_special_type() -> None: + """Test decoding non-special type returns original dict.""" + data = {"foo": "bar"} + + result = _json_decoder(data) + + assert result == {"foo": "bar"} + + +def test_roundtrip_json_encoding() -> None: + """Test roundtrip encoding and decoding using AA JSON utilities.""" + test_data = { + "datetime": datetime.datetime(2025, 12, 14, 10, 30, 0), + "date": datetime.date(2025, 12, 14), + "time": datetime.time(10, 30, 45), + "timedelta": datetime.timedelta(hours=2), + "decimal": Decimal("99.99"), + "bytes": b"\xff\xfe", + "uuid": UUID("12345678-1234-5678-1234-567812345678"), + "set": {1, 2, 3}, + } + + encoded: dict[str, object] = {} + for k, v in test_data.items(): + try: + encoded[k] = _json_encoder(v) + except TypeError: + encoded[k] = v + + # Encode via AA serializer + json_str = encode_json(encoded) + + # Decode via AA parser + our special-type walker + raw = decode_json(json_str) + result = _decode_special_types(raw) + + assert result["datetime"] == test_data["datetime"] + assert result["date"] == test_data["date"] + assert result["time"] == test_data["time"] + assert result["timedelta"] == test_data["timedelta"] + assert result["decimal"] == test_data["decimal"] + assert result["bytes"] == test_data["bytes"] + assert result["uuid"] == test_data["uuid"] + assert result["set"] == test_data["set"] From 62f0d804504eae4a8e0973755e2aabd69c7236fe Mon Sep 17 00:00:00 2001 From: Cody Fincher Date: Sat, 17 Jan 2026 21:20:40 +0000 Subject: [PATCH 02/10] chore: fix code style and add documentation for cache module - Remove banned `from __future__ import annotations` imports - Fix union syntax to use Optional/Union instead of X | Y - Add proper docstrings replacing inline comments - Move non-circular imports to top-level - Add cache module documentation (usage guide and API reference) - Quote forward references for TYPE_CHECKING imports --- advanced_alchemy/cache/__init__.py | 57 +++-- advanced_alchemy/cache/_null.py | 23 +- advanced_alchemy/cache/config.py | 10 +- advanced_alchemy/cache/manager.py | 60 +++-- advanced_alchemy/cache/serializers.py | 14 +- docs/reference/cache.rst | 55 ++++ docs/reference/index.rst | 1 + docs/usage/caching.rst | 346 ++++++++++++++++++++++++++ docs/usage/index.rst | 1 + 9 files changed, 497 insertions(+), 70 deletions(-) create mode 100644 docs/reference/cache.rst create mode 100644 docs/usage/caching.rst diff --git a/advanced_alchemy/cache/__init__.py b/advanced_alchemy/cache/__init__.py index 400d4bcdf..f9cba561f 100644 --- a/advanced_alchemy/cache/__init__.py +++ b/advanced_alchemy/cache/__init__.py @@ -13,34 +13,39 @@ - Per-process singleflight to reduce stampedes on cache miss Example: - Basic setup with memory cache:: + Using the config system (recommended):: - from advanced_alchemy.cache import CacheConfig, CacheManager + from advanced_alchemy.cache import CacheConfig + from advanced_alchemy.config import SQLAlchemyAsyncConfig from advanced_alchemy.repository import ( SQLAlchemyAsyncRepository, ) - # Configure caching - cache_config = CacheConfig( - backend="dogpile.cache.memory", - expiration_time=300, # 5 minutes + # Configure database with caching + db_config = SQLAlchemyAsyncConfig( + connection_string="sqlite+aiosqlite:///app.db", + cache_config=CacheConfig( + backend="dogpile.cache.memory", + expiration_time=300, + ), ) - cache_manager = CacheManager(cache_config) + + # Cache listeners are auto-registered, cache_manager is stored + # in session.info and auto-retrieved by repositories. - # Use with repository class UserRepository(SQLAlchemyAsyncRepository[User]): model_type = User - cache_config = cache_config - repo = UserRepository( - session=session, cache_manager=cache_manager - ) - user = await repo.get(1) # First call hits DB and caches - user = await repo.get( - 1 - ) # Second call returns cached result + async with db_config.get_session() as session: + repo = UserRepository(session=session) + user = await repo.get( + 1 + ) # First call hits DB and caches + user = await repo.get( + 1 + ) # Second call returns cached result Redis configuration:: @@ -62,14 +67,24 @@ class UserRepository(SQLAlchemyAsyncRepository[User]): Without dogpile.cache installed, the cache manager will use a NullRegion that provides the same interface but doesn't cache. -Setup: - To enable automatic cache invalidation, call ``setup_cache_listeners()`` - during application initialization:: +Manual Setup: + If not using the config system, call ``setup_cache_listeners()`` + during application initialization and pass cache_manager explicitly:: - from advanced_alchemy.cache import setup_cache_listeners + from advanced_alchemy.cache import ( + CacheConfig, + CacheManager, + setup_cache_listeners, + ) - # Call once at startup + cache_manager = CacheManager( + CacheConfig(backend="dogpile.cache.memory") + ) setup_cache_listeners() + + repo = UserRepository( + session=session, cache_manager=cache_manager + ) """ from advanced_alchemy._listeners import setup_cache_listeners diff --git a/advanced_alchemy/cache/_null.py b/advanced_alchemy/cache/_null.py index 4bcfa21a0..e05d163cc 100644 --- a/advanced_alchemy/cache/_null.py +++ b/advanced_alchemy/cache/_null.py @@ -1,16 +1,21 @@ """Null cache region implementation for when dogpile.cache is not installed.""" -from __future__ import annotations - -from typing import Any, Callable, TypeVar +from typing import Any, Callable, Optional, TypeVar __all__ = ("NullRegion",) T = TypeVar("T") -# Sentinel value to indicate a cache miss (compatible with dogpile.cache.api.NO_VALUE) class _NoValue: + """Sentinel value to indicate a cache miss. + + This is compatible with ``dogpile.cache.api.NO_VALUE`` and is used + when dogpile.cache is not installed to maintain API compatibility. + """ + + __slots__ = () + def __repr__(self) -> str: return "" @@ -30,7 +35,7 @@ class NullRegion: __slots__ = () - def get(self, key: str, expiration_time: int | None = None) -> Any: + def get(self, key: str, expiration_time: Optional[int] = None) -> Any: """Get a value from the cache (always returns NO_VALUE). Args: @@ -46,7 +51,7 @@ def get_or_create( self, key: str, creator: Callable[[], T], - expiration_time: int | None = None, + expiration_time: Optional[int] = None, ) -> T: """Get or create a value (always calls the creator). @@ -81,10 +86,10 @@ def invalidate(self) -> None: def configure( self, backend: str, - expiration_time: int | None = None, - arguments: dict[str, Any] | None = None, + expiration_time: Optional[int] = None, + arguments: Optional[dict[str, Any]] = None, **kwargs: Any, - ) -> NullRegion: + ) -> "NullRegion": """Configure the region (no-op, returns self). Args: diff --git a/advanced_alchemy/cache/config.py b/advanced_alchemy/cache/config.py index d0a007a51..c10328a6c 100644 --- a/advanced_alchemy/cache/config.py +++ b/advanced_alchemy/cache/config.py @@ -1,9 +1,7 @@ """Configuration classes for dogpile.cache integration.""" -from __future__ import annotations - from dataclasses import dataclass, field -from typing import Any, Callable +from typing import Any, Callable, Optional __all__ = ("CacheConfig",) @@ -88,7 +86,7 @@ class CacheConfig: debugging or testing. """ - serializer: Callable[[Any], bytes] | None = None + serializer: Optional[Callable[[Any], bytes]] = None """Custom serializer function. If ``None``, uses the default JSON serializer which handles @@ -97,7 +95,7 @@ class CacheConfig: The function should accept any value and return bytes. """ - deserializer: Callable[[bytes, type[Any]], Any] | None = None + deserializer: Optional[Callable[[bytes, "type[Any]"], Any]] = None """Custom deserializer function. If ``None``, uses the default JSON deserializer. @@ -106,7 +104,7 @@ class CacheConfig: returning an instance of that class. """ - region_factory: Callable[[CacheConfig], Any] | None = None + region_factory: Optional[Callable[["CacheConfig"], Any]] = None """Optional hook to construct a cache region instance. This exists to keep the repository/service integration stable even if diff --git a/advanced_alchemy/cache/manager.py b/advanced_alchemy/cache/manager.py index 92629436c..7f8cb3604 100644 --- a/advanced_alchemy/cache/manager.py +++ b/advanced_alchemy/cache/manager.py @@ -1,23 +1,20 @@ """Cache manager for dogpile.cache integration with SQLAlchemy repositories.""" -from __future__ import annotations - import asyncio import base64 import concurrent.futures import logging import threading import uuid +from collections.abc import Coroutine from functools import partial -from typing import TYPE_CHECKING, Any, Callable, TypeVar, cast +from typing import TYPE_CHECKING, Any, Callable, Optional, TypeVar, Union, cast from advanced_alchemy.cache._null import NO_VALUE, NullRegion from advanced_alchemy.cache.serializers import default_deserializer, default_serializer from advanced_alchemy.utils.sync_tools import async_ if TYPE_CHECKING: - from collections.abc import Coroutine - from dogpile.cache import CacheRegion from advanced_alchemy.cache.config import CacheConfig @@ -31,7 +28,7 @@ T = TypeVar("T") -# Check if dogpile.cache is installed +# Runtime detection of dogpile.cache availability _dogpile_cache_installed = False _dogpile_no_value: Any = NO_VALUE _make_region: Any = None @@ -44,8 +41,11 @@ except ImportError: pass -DOGPILE_CACHE_INSTALLED = _dogpile_cache_installed -DOGPILE_NO_VALUE = _dogpile_no_value +DOGPILE_CACHE_INSTALLED: bool = _dogpile_cache_installed +"""Whether dogpile.cache is installed and available.""" + +DOGPILE_NO_VALUE: Any = _dogpile_no_value +"""Sentinel value indicating a cache miss (from dogpile or NullRegion).""" class CacheManager: @@ -107,23 +107,29 @@ class CacheManager: "config", ) - def __init__(self, config: CacheConfig) -> None: + def __init__(self, config: "CacheConfig") -> None: """Initialize the cache manager. Args: config: Configuration for the cache region. + + Note: + Model version tokens are stored in-cache for cross-process consistency. + A random token is used per commit to avoid lost updates without requiring + backend-specific atomic increment support. + + Per-process singleflight registries (async and sync) are best-effort; + they reduce stampedes within a single process but do not provide + cross-process locking. """ self.config = config - self._region: CacheRegion | NullRegion | None = None # Model version tokens are stored in-cache for cross-process consistency. # Use a random token per commit to avoid lost updates without requiring # backend-specific atomic increment support. + self._region: Optional[Union["CacheRegion", NullRegion]] = None # noqa: UP037 self._model_versions: dict[str, str] = {} - - # Per-process singleflight registries (async and sync). - # These are best-effort; they reduce stampedes within a single process. self._async_inflight: dict[str, asyncio.Task[Any]] = {} - self._async_inflight_lock: asyncio.Lock | None = None + self._async_inflight_lock: Optional[asyncio.Lock] = None self._sync_inflight: dict[str, concurrent.futures.Future[Any]] = {} self._sync_inflight_lock = threading.Lock() @@ -135,7 +141,7 @@ def _inflight_lock_async(self) -> asyncio.Lock: return self._async_inflight_lock @property - def region(self) -> CacheRegion | NullRegion: + def region(self) -> Union["CacheRegion", NullRegion]: """Get the cache region, creating it if necessary. The region is lazily initialized on first access. If dogpile.cache @@ -149,7 +155,7 @@ def region(self) -> CacheRegion | NullRegion: self._region = self._create_region() return self._region - def _create_region(self) -> CacheRegion | NullRegion: # noqa: PLR0911 + def _create_region(self) -> Union["CacheRegion", NullRegion]: # noqa: PLR0911 """Create and configure the dogpile.cache region. Returns: @@ -158,7 +164,7 @@ def _create_region(self) -> CacheRegion | NullRegion: # noqa: PLR0911 """ if self.config.region_factory is not None: try: - created_region = cast("CacheRegion | NullRegion", self.config.region_factory(self.config)) + created_region = cast("Union[CacheRegion, NullRegion]", self.config.region_factory(self.config)) except Exception: logger.exception("Failed to construct cache region via region_factory, using NullRegion") return NullRegion() @@ -177,7 +183,7 @@ def _create_region(self) -> CacheRegion | NullRegion: # noqa: PLR0911 try: if _make_region is None: # pragma: no cover return NullRegion() - region: CacheRegion = _make_region().configure( + region: "CacheRegion" = _make_region().configure( # noqa: UP037 self.config.backend, expiration_time=self.config.expiration_time, arguments=self.config.arguments, @@ -249,7 +255,7 @@ def get_or_create_sync( self, key: str, creator: Callable[[], T], - expiration_time: int | None = None, + expiration_time: Optional[int] = None, ) -> T: """Get a value from cache or create it using the creator function (sync). @@ -291,7 +297,7 @@ def get_entity_sync( model_name: str, entity_id: Any, model_class: type[T], - ) -> T | None: + ) -> Optional[T]: """Get a cached entity by model name and ID (sync). Args: @@ -405,7 +411,7 @@ def get_model_version_sync(self, model_name: str) -> str: return "0" - def get_list_sync(self, key: str, model_class: type[T]) -> list[T] | None: + def get_list_sync(self, key: str, model_class: type[T]) -> Optional[list[T]]: """Get a cached list of entities (sync). The list is stored as base64-encoded serialized entity payloads. @@ -452,7 +458,7 @@ def set_list_sync(self, key: str, items: list[Any]) -> None: except Exception: logger.exception("Failed to serialize cached list for key %s", key) - def get_list_and_count_sync(self, key: str, model_class: type[T]) -> tuple[list[T], int] | None: + def get_list_and_count_sync(self, key: str, model_class: type[T]) -> Optional[tuple[list[T], int]]: """Get a cached list+count payload (sync).""" cached = self.get_sync(key) if cached is DOGPILE_NO_VALUE or cached is NO_VALUE: @@ -500,7 +506,7 @@ def singleflight_sync(self, key: str, creator: Callable[[], T]) -> T: cross-process locking. """ with self._sync_inflight_lock: - future: concurrent.futures.Future[Any] | None = self._sync_inflight.get(key) + future: Optional[concurrent.futures.Future[Any]] = self._sync_inflight.get(key) if future is None: future = concurrent.futures.Future() self._sync_inflight[key] = future @@ -572,7 +578,7 @@ async def get_or_create_async( self, key: str, creator: Callable[[], T], - expiration_time: int | None = None, + expiration_time: Optional[int] = None, ) -> T: """Get a value from cache or create it using the creator function (async). @@ -600,7 +606,7 @@ async def get_entity_async( model_name: str, entity_id: Any, model_class: type[T], - ) -> T | None: + ) -> Optional[T]: """Get a cached entity by model name and ID (async). Args: @@ -670,7 +676,7 @@ async def get_model_version_async(self, model_name: str) -> str: """ return await async_(self.get_model_version_sync)(model_name) - async def get_list_async(self, key: str, model_class: type[T]) -> list[T] | None: + async def get_list_async(self, key: str, model_class: type[T]) -> Optional[list[T]]: """Get a cached list of entities (async).""" return await async_(self.get_list_sync)(key, model_class) @@ -678,7 +684,7 @@ async def set_list_async(self, key: str, items: list[Any]) -> None: """Cache a list of entities (async).""" await async_(self.set_list_sync)(key, items) - async def get_list_and_count_async(self, key: str, model_class: type[T]) -> tuple[list[T], int] | None: + async def get_list_and_count_async(self, key: str, model_class: type[T]) -> Optional[tuple[list[T], int]]: """Get a cached list+count payload (async).""" return await async_(self.get_list_and_count_sync)(key, model_class) diff --git a/advanced_alchemy/cache/serializers.py b/advanced_alchemy/cache/serializers.py index 9d5c40622..c70fd3301 100644 --- a/advanced_alchemy/cache/serializers.py +++ b/advanced_alchemy/cache/serializers.py @@ -1,12 +1,12 @@ """Serialization utilities for caching SQLAlchemy models.""" -from __future__ import annotations - from datetime import date, datetime, time, timedelta from decimal import Decimal -from typing import Any, TypeVar, cast +from typing import Any, TypeVar, Union, cast from uuid import UUID +from sqlalchemy import inspect as sa_inspect + from advanced_alchemy._serialization import decode_json, encode_json __all__ = ( @@ -16,9 +16,11 @@ T = TypeVar("T") -# Metadata keys used in serialized data _MODEL_KEY = "__aa_model__" +"""Metadata key for the model class name in serialized data.""" + _TABLE_KEY = "__aa_table__" +"""Metadata key for the table name in serialized data.""" def _json_encoder(obj: Any) -> Any: # noqa: PLR0911 @@ -56,7 +58,7 @@ def _json_encoder(obj: Any) -> Any: # noqa: PLR0911 if isinstance(obj, UUID): return {"__type__": "uuid", "value": str(obj)} if isinstance(obj, (set, frozenset)): - items: list[Any] = list(cast("set[Any] | frozenset[Any]", obj)) # type: ignore[redundant-cast] + items: list[Any] = list(cast("Union[set[Any], frozenset[Any]]", obj)) # type: ignore[redundant-cast] return {"__type__": "set", "value": items} msg = f"Object of type {type(obj).__name__} is not JSON serializable" raise TypeError(msg) @@ -147,8 +149,6 @@ def default_serializer(model: Any) -> bytes: data = default_serializer(user) # b'{"__aa_model__": "User", "__aa_table__": "users", "id": 1, ...}' """ - from sqlalchemy import inspect as sa_inspect - mapper = sa_inspect(model.__class__) data: dict[str, Any] = { _MODEL_KEY: model.__class__.__name__, diff --git a/docs/reference/cache.rst b/docs/reference/cache.rst new file mode 100644 index 000000000..46c2fc658 --- /dev/null +++ b/docs/reference/cache.rst @@ -0,0 +1,55 @@ +===== +Cache +===== + +.. module:: advanced_alchemy.cache + +The cache module provides optional integration with `dogpile.cache`_ for caching +SQLAlchemy model instances. It supports multiple backends and provides automatic +cache invalidation when models are modified. + +.. _dogpile.cache: https://dogpilecache.sqlalchemy.org/ + +Installation +------------ + +The cache module requires the optional ``dogpile.cache`` dependency: + +.. code-block:: bash + + pip install advanced-alchemy[dogpile] + +Without this dependency, the cache manager will use a ``NullRegion`` that provides +the same interface but doesn't actually cache anything. + +Configuration +------------- + +.. autoclass:: advanced_alchemy.cache.CacheConfig + :members: + :show-inheritance: + +Cache Manager +------------- + +.. autoclass:: advanced_alchemy.cache.CacheManager + :members: + :show-inheritance: + +Serialization +------------- + +.. autofunction:: advanced_alchemy.cache.default_serializer + +.. autofunction:: advanced_alchemy.cache.default_deserializer + +Setup +----- + +.. autofunction:: advanced_alchemy._listeners.setup_cache_listeners + +Constants +--------- + +.. autodata:: advanced_alchemy.cache.DOGPILE_CACHE_INSTALLED + :annotation: diff --git a/docs/reference/index.rst b/docs/reference/index.rst index 51b08489f..c94391e5a 100644 --- a/docs/reference/index.rst +++ b/docs/reference/index.rst @@ -16,6 +16,7 @@ Core Components base mixins/index config/index + cache operations types exceptions diff --git a/docs/usage/caching.rst b/docs/usage/caching.rst new file mode 100644 index 000000000..8bccc0f70 --- /dev/null +++ b/docs/usage/caching.rst @@ -0,0 +1,346 @@ +======= +Caching +======= + +Advanced Alchemy provides optional caching support through integration with +`dogpile.cache`_. This allows you to cache SQLAlchemy model instances using +various backends (Redis, Memcached, file, memory) with automatic cache +invalidation when models are modified. + +.. _dogpile.cache: https://dogpilecache.sqlalchemy.org/ + +Installation +------------ + +Install the optional caching dependency: + +.. code-block:: bash + + pip install advanced-alchemy[dogpile] + +Quick Start +----------- + +Basic setup with in-memory caching using the config system: + +.. code-block:: python + + from advanced_alchemy.cache import CacheConfig + from advanced_alchemy.config import SQLAlchemyAsyncConfig + from advanced_alchemy.repository import SQLAlchemyAsyncRepository + + # Configure caching via SQLAlchemy config + db_config = SQLAlchemyAsyncConfig( + connection_string="sqlite+aiosqlite:///app.db", + cache_config=CacheConfig( + backend="dogpile.cache.memory", + expiration_time=300, # 5 minutes + ), + ) + + # Cache listeners are automatically registered when cache_config is set. + # The cache_manager is stored in session.info and auto-retrieved by repositories. + + + class UserRepository(SQLAlchemyAsyncRepository[User]): + model_type = User + + + # Repository automatically uses cache_manager from session.info + async with db_config.get_session() as session: + repo = UserRepository(session=session) + + # First call hits database and caches the result + user = await repo.get(user_id) + + # Second call returns cached result + user = await repo.get(user_id) + +Configuration Options +--------------------- + +The ``CacheConfig`` dataclass provides several configuration options: + +.. code-block:: python + + from advanced_alchemy.cache import CacheConfig + + config = CacheConfig( + # Cache backend (see Backend Configuration below) + backend="dogpile.cache.redis", + + # Default TTL in seconds (default: 3600) + expiration_time=3600, + + # Backend-specific arguments + arguments={ + "host": "localhost", + "port": 6379, + "db": 0, + }, + + # Key prefix to avoid collisions (default: "aa:") + key_prefix="myapp:", + + # Enable/disable caching globally (default: True) + enabled=True, + ) + +Backend Configuration +--------------------- + +Memory Backend +~~~~~~~~~~~~~~ + +Best for development and testing: + +.. code-block:: python + + config = CacheConfig( + backend="dogpile.cache.memory", + expiration_time=300, + ) + +Redis Backend +~~~~~~~~~~~~~ + +Recommended for production with distributed systems: + +.. code-block:: python + + config = CacheConfig( + backend="dogpile.cache.redis", + expiration_time=3600, + arguments={ + "host": "localhost", + "port": 6379, + "db": 0, + "distributed_lock": True, # Enable distributed locking + }, + ) + +Memcached Backend +~~~~~~~~~~~~~~~~~ + +Alternative for high-performance caching: + +.. code-block:: python + + config = CacheConfig( + backend="dogpile.cache.memcached", + expiration_time=3600, + arguments={ + "url": ["127.0.0.1:11211"], + }, + ) + +Null Backend +~~~~~~~~~~~~ + +Disables caching (useful for testing): + +.. code-block:: python + + config = CacheConfig( + backend="dogpile.cache.null", + ) + + # Or simply disable caching + config = CacheConfig(enabled=False) + +Repository Integration +---------------------- + +The cache manager integrates with repositories through the ``cache_manager`` parameter: + +.. code-block:: python + + from advanced_alchemy.repository import SQLAlchemyAsyncRepository + + class UserRepository(SQLAlchemyAsyncRepository[User]): + model_type = User + + + # Create repository with caching + repo = UserRepository( + session=session, + cache_manager=cache_manager, + auto_expunge=True, # Recommended with caching + ) + + # These methods support caching: + user = await repo.get(user_id) # Cached by entity ID + users = await repo.list() # Cached with version-based invalidation + users, count = await repo.list_and_count() # Cached with version-based invalidation + +Bypassing the Cache +~~~~~~~~~~~~~~~~~~~ + +You can bypass the cache for specific queries: + +.. code-block:: python + + # Force database fetch, skip cache + user = await repo.get(user_id, use_cache=False) + users = await repo.list(use_cache=False) + +Automatic Cache Invalidation +---------------------------- + +When using the config system with ``cache_config``, cache listeners are +automatically registered (controlled by ``enable_cache_listener=True``, the default). +Cache entries are automatically invalidated when models are created, updated, or deleted. + +The invalidation is transaction-aware: + +- Invalidations are deferred until the transaction commits +- If the transaction rolls back, invalidations are discarded +- Entity caches are invalidated by ID +- List caches use version-based invalidation (bumps a version token) + +Version-Based List Invalidation +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +List queries use version-based invalidation. When any entity of a model type +is modified, a version token is bumped, which invalidates all list caches +for that model: + +.. code-block:: python + + # First call caches with version token "abc123" + users = await repo.list() + + # Modify any user + user.name = "New Name" + await repo.update(user) + await session.commit() # Version token bumped to "def456" + + # Next call sees new version, fetches fresh data + users = await repo.list() + +Singleflight (Stampede Protection) +---------------------------------- + +The cache manager includes per-process singleflight to prevent cache stampedes. +When multiple concurrent requests try to fetch the same uncached data, only +one request hits the database: + +.. code-block:: python + + import asyncio + + # All 10 concurrent calls result in only 1 database query + results = await asyncio.gather(*[ + repo.get(user_id) for _ in range(10) + ]) + +Custom Serialization +-------------------- + +By default, models are serialized to JSON. You can provide custom serializers +for different serialization formats: + +.. code-block:: python + + import msgpack + + def msgpack_serializer(model): + # Convert model to dict and serialize with msgpack + from sqlalchemy import inspect + mapper = inspect(model.__class__) + data = {col.key: getattr(model, col.key) for col in mapper.columns} + return msgpack.packb(data) + + def msgpack_deserializer(data, model_class): + unpacked = msgpack.unpackb(data) + return model_class(**unpacked) + + config = CacheConfig( + backend="dogpile.cache.redis", + serializer=msgpack_serializer, + deserializer=msgpack_deserializer, + ) + +.. warning:: + + The default JSON serializer only serializes column values, not relationships. + Cached instances are detached and accessing lazy-loaded relationships will + raise ``DetachedInstanceError``. Use ``session.merge()`` if you need + relationship access. + +Performance Considerations +-------------------------- + +1. **Use auto_expunge=True**: When using caching, set ``auto_expunge=True`` on + your repository to ensure cached entities are properly detached. + +2. **Choose the right backend**: Use Redis or Memcached for production with + multiple application instances. Memory backend is only suitable for + single-instance deployments or development. + +3. **Set appropriate TTLs**: Balance between cache hit rate and data freshness. + Shorter TTLs mean more database queries but fresher data. + +4. **Key prefix**: Use unique key prefixes when sharing a cache backend with + other applications to avoid key collisions. + +5. **Graceful degradation**: If dogpile.cache is not installed, the cache + manager automatically falls back to a no-op implementation. + +Example: Full Application Setup +------------------------------- + +.. code-block:: python + + from litestar import Litestar + from litestar.contrib.sqlalchemy.plugins import SQLAlchemyPlugin + + from advanced_alchemy.cache import CacheConfig + from advanced_alchemy.config import SQLAlchemyAsyncConfig + from advanced_alchemy.repository import SQLAlchemyAsyncRepository + + + # Configure database with caching + db_config = SQLAlchemyAsyncConfig( + connection_string="postgresql+asyncpg://user:pass@localhost/db", + cache_config=CacheConfig( + backend="dogpile.cache.redis", + expiration_time=3600, + arguments={"host": "localhost", "port": 6379, "db": 0}, + key_prefix="myapp:", + ), + ) + + + class UserRepository(SQLAlchemyAsyncRepository[User]): + model_type = User + + + async def get_user(session: AsyncSession, user_id: int) -> User: + # Repository auto-retrieves cache_manager from session.info + repo = UserRepository(session=session, auto_expunge=True) + return await repo.get(user_id) + + + app = Litestar( + plugins=[SQLAlchemyPlugin(config=db_config)], + ... + ) + +Manual Setup (Without Config) +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +If you're not using the config system, you can set up caching manually: + +.. code-block:: python + + from advanced_alchemy.cache import CacheConfig, CacheManager, setup_cache_listeners + + # Create cache manager + cache_manager = CacheManager(CacheConfig(backend="dogpile.cache.memory")) + + # Register listeners once at startup + setup_cache_listeners() + + # Pass cache_manager explicitly to repositories + repo = UserRepository(session=session, cache_manager=cache_manager) diff --git a/docs/usage/index.rst b/docs/usage/index.rst index b6c9638e2..5c5d8e7df 100644 --- a/docs/usage/index.rst +++ b/docs/usage/index.rst @@ -19,6 +19,7 @@ This guide demonstrates building a complete blog system using Advanced Alchemy's services routing types + caching cli database_seeding From af67f86a988805022c01fa4e67b1229d7e24eac9 Mon Sep 17 00:00:00 2001 From: Cody Fincher Date: Sat, 17 Jan 2026 21:23:33 +0000 Subject: [PATCH 03/10] fix: complete rebase integration of bind_group and caching - Add missing use_cache parameter to get() method signature - Add bind_group documentation to list() and list_and_count() docstrings - Add missing TYPE_CHECKING imports for AsyncSession, scoped_session, async_scoped_session, and CacheManager in _listeners.py - Add __init__.py to tests/unit/test_cache/ directory --- advanced_alchemy/_listeners.py | 4 +++- advanced_alchemy/repository/_async.py | 4 ++++ advanced_alchemy/repository/_sync.py | 4 ++++ advanced_alchemy/service/_async.py | 2 ++ advanced_alchemy/service/_sync.py | 2 ++ tests/unit/test_cache/__init__.py | 1 + 6 files changed, 16 insertions(+), 1 deletion(-) create mode 100644 tests/unit/test_cache/__init__.py diff --git a/advanced_alchemy/_listeners.py b/advanced_alchemy/_listeners.py index 1e687ff0f..e4cf977e9 100644 --- a/advanced_alchemy/_listeners.py +++ b/advanced_alchemy/_listeners.py @@ -11,9 +11,11 @@ from sqlalchemy.inspection import inspect if TYPE_CHECKING: - from sqlalchemy.orm import Session, UOWTransaction + from sqlalchemy.ext.asyncio import AsyncSession, async_scoped_session + from sqlalchemy.orm import Session, UOWTransaction, scoped_session from sqlalchemy.orm.state import InstanceState + from advanced_alchemy.cache import CacheManager from advanced_alchemy.types.file_object import FileObjectSessionTracker, StorageRegistry _active_file_operations: set[asyncio.Task[Any]] = set() diff --git a/advanced_alchemy/repository/_async.py b/advanced_alchemy/repository/_async.py index 519ceb3d8..b06843268 100644 --- a/advanced_alchemy/repository/_async.py +++ b/advanced_alchemy/repository/_async.py @@ -1511,6 +1511,7 @@ async def get( execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, with_for_update: ForUpdateParameter = None, + use_cache: bool = True, bind_group: Optional[str] = None, ) -> ModelT: """Get instance identified by `item_id`. @@ -1527,6 +1528,7 @@ async def get( execution_options: Set default execution options uniquify: Optionally apply the ``unique()`` method to results before returning. with_for_update: Optional FOR UPDATE clause / parameters to apply to the SELECT statement. + use_cache: Whether to use caching for this query (default True). bind_group: Optional routing group to use for the operation. Returns: @@ -2183,6 +2185,7 @@ async def list_and_count( execution_options: Set default execution options uniquify: Optionally apply the ``unique()`` method to results before returning. use_cache: Whether to use the cache for this query. Defaults to ``True``. + bind_group: Optional routing group to use for the operation. **kwargs: Instance attribute value filters. Returns: @@ -2724,6 +2727,7 @@ async def list( execution_options: Set default execution options uniquify: Optionally apply the ``unique()`` method to results before returning. use_cache: Whether to use the cache for this query. Defaults to ``True``. + bind_group: Optional routing group to use for the operation. **kwargs: Instance attribute value filters. Returns: diff --git a/advanced_alchemy/repository/_sync.py b/advanced_alchemy/repository/_sync.py index 84177015b..5870c570b 100644 --- a/advanced_alchemy/repository/_sync.py +++ b/advanced_alchemy/repository/_sync.py @@ -1510,6 +1510,7 @@ def get( execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, with_for_update: ForUpdateParameter = None, + use_cache: bool = True, bind_group: Optional[str] = None, ) -> ModelT: """Get instance identified by `item_id`. @@ -1526,6 +1527,7 @@ def get( execution_options: Set default execution options uniquify: Optionally apply the ``unique()`` method to results before returning. with_for_update: Optional FOR UPDATE clause / parameters to apply to the SELECT statement. + use_cache: Whether to use caching for this query (default True). bind_group: Optional routing group to use for the operation. Returns: @@ -2182,6 +2184,7 @@ def list_and_count( execution_options: Set default execution options uniquify: Optionally apply the ``unique()`` method to results before returning. use_cache: Whether to use the cache for this query. Defaults to ``True``. + bind_group: Optional routing group to use for the operation. **kwargs: Instance attribute value filters. Returns: @@ -2721,6 +2724,7 @@ def list( execution_options: Set default execution options uniquify: Optionally apply the ``unique()`` method to results before returning. use_cache: Whether to use the cache for this query. Defaults to ``True``. + bind_group: Optional routing group to use for the operation. **kwargs: Instance attribute value filters. Returns: diff --git a/advanced_alchemy/service/_async.py b/advanced_alchemy/service/_async.py index 5fc6b5828..dd8c814ac 100644 --- a/advanced_alchemy/service/_async.py +++ b/advanced_alchemy/service/_async.py @@ -532,6 +532,7 @@ async def list_and_count( execution_options: Set default execution options uniquify: Optionally apply the ``unique()`` method to results before returning. use_cache: Whether to use the repository cache for this query. + bind_group: Optional routing group to use for the operation. **kwargs: Instance attribute value filters. Returns: @@ -629,6 +630,7 @@ async def list( execution_options: Set default execution options uniquify: Optionally apply the ``unique()`` method to results before returning. use_cache: Whether to use the repository cache for this query. + bind_group: Optional routing group to use for the operation. **kwargs: Instance attribute value filters. Returns: diff --git a/advanced_alchemy/service/_sync.py b/advanced_alchemy/service/_sync.py index 25ff487e6..c027b2e04 100644 --- a/advanced_alchemy/service/_sync.py +++ b/advanced_alchemy/service/_sync.py @@ -531,6 +531,7 @@ def list_and_count( execution_options: Set default execution options uniquify: Optionally apply the ``unique()`` method to results before returning. use_cache: Whether to use the repository cache for this query. + bind_group: Optional routing group to use for the operation. **kwargs: Instance attribute value filters. Returns: @@ -628,6 +629,7 @@ def list( execution_options: Set default execution options uniquify: Optionally apply the ``unique()`` method to results before returning. use_cache: Whether to use the repository cache for this query. + bind_group: Optional routing group to use for the operation. **kwargs: Instance attribute value filters. Returns: diff --git a/tests/unit/test_cache/__init__.py b/tests/unit/test_cache/__init__.py new file mode 100644 index 000000000..d65b8afca --- /dev/null +++ b/tests/unit/test_cache/__init__.py @@ -0,0 +1 @@ +# ruff: noqa From 3ad92937c8637b2b6afe2475fb7e341e69496db6 Mon Sep 17 00:00:00 2001 From: Cody Fincher Date: Sat, 17 Jan 2026 22:32:14 +0000 Subject: [PATCH 04/10] fix: add cache region type protocols and fix type errors - Add SyncCacheRegionProtocol for sync cache region interface - Add AsyncCacheRegionProtocol for async cache region interface - Add NO_VALUE sentinel to _null.py exports - Add _cache_manager class attribute to repository classes - Fix pyright/mypy ignore comment placement on dogpile imports - Update manager.py to use SyncCacheRegionProtocol --- advanced_alchemy/cache/_null.py | 71 ++++++++++++++++++++++++++- advanced_alchemy/cache/manager.py | 63 +++++++++++++++--------- advanced_alchemy/repository/_async.py | 8 +++ advanced_alchemy/repository/_sync.py | 8 +++ 4 files changed, 124 insertions(+), 26 deletions(-) diff --git a/advanced_alchemy/cache/_null.py b/advanced_alchemy/cache/_null.py index e05d163cc..c7ed0ff47 100644 --- a/advanced_alchemy/cache/_null.py +++ b/advanced_alchemy/cache/_null.py @@ -1,12 +1,79 @@ """Null cache region implementation for when dogpile.cache is not installed.""" -from typing import Any, Callable, Optional, TypeVar +from collections.abc import Awaitable +from typing import Any, Callable, Optional, Protocol, TypeVar -__all__ = ("NullRegion",) +__all__ = ( + "NO_VALUE", + "AsyncCacheRegionProtocol", + "NullRegion", + "SyncCacheRegionProtocol", +) T = TypeVar("T") +class SyncCacheRegionProtocol(Protocol): + """Protocol defining the synchronous cache region interface used by CacheManager. + + This protocol is compatible with both dogpile.cache.CacheRegion and NullRegion. + """ + + def get(self, key: str, expiration_time: Optional[int] = None) -> Any: ... + + def get_or_create( + self, + key: str, + creator: Callable[[], T], + expiration_time: Optional[int] = None, + ) -> T: ... + + def set(self, key: str, value: Any) -> None: ... + + def delete(self, key: str) -> None: ... + + def invalidate(self) -> None: ... + + def configure( + self, + backend: str, + expiration_time: Optional[int] = None, + arguments: Optional[dict[str, Any]] = None, + **kwargs: Any, + ) -> "SyncCacheRegionProtocol": ... + + +class AsyncCacheRegionProtocol(Protocol): + """Protocol defining the asynchronous cache region interface. + + This protocol defines async versions of cache region operations, + suitable for use with native async cache backends. + """ + + async def get(self, key: str, expiration_time: Optional[int] = None) -> Any: ... + + async def get_or_create( + self, + key: str, + creator: Callable[[], Awaitable[T]], + expiration_time: Optional[int] = None, + ) -> T: ... + + async def set(self, key: str, value: Any) -> None: ... + + async def delete(self, key: str) -> None: ... + + async def invalidate(self) -> None: ... + + async def configure( + self, + backend: str, + expiration_time: Optional[int] = None, + arguments: Optional[dict[str, Any]] = None, + **kwargs: Any, + ) -> "AsyncCacheRegionProtocol": ... + + class _NoValue: """Sentinel value to indicate a cache miss. diff --git a/advanced_alchemy/cache/manager.py b/advanced_alchemy/cache/manager.py index 7f8cb3604..bfd4a1c37 100644 --- a/advanced_alchemy/cache/manager.py +++ b/advanced_alchemy/cache/manager.py @@ -8,19 +8,18 @@ import uuid from collections.abc import Coroutine from functools import partial -from typing import TYPE_CHECKING, Any, Callable, Optional, TypeVar, Union, cast +from typing import TYPE_CHECKING, Any, Callable, Optional, TypeVar, cast -from advanced_alchemy.cache._null import NO_VALUE, NullRegion +from advanced_alchemy.cache._null import NO_VALUE, NullRegion, SyncCacheRegionProtocol from advanced_alchemy.cache.serializers import default_deserializer, default_serializer from advanced_alchemy.utils.sync_tools import async_ if TYPE_CHECKING: - from dogpile.cache import CacheRegion - from advanced_alchemy.cache.config import CacheConfig __all__ = ( "DOGPILE_CACHE_INSTALLED", + "DOGPILE_NO_VALUE", "CacheManager", ) @@ -28,23 +27,41 @@ T = TypeVar("T") -# Runtime detection of dogpile.cache availability -_dogpile_cache_installed = False -_dogpile_no_value: Any = NO_VALUE -_make_region: Any = None +# Type alias for the make_region factory function +MakeRegionFunc = Callable[[], SyncCacheRegionProtocol] + + +# Stub implementations for when dogpile.cache is not installed +def _make_region_stub() -> SyncCacheRegionProtocol: + """Stub make_region that returns a NullRegion when dogpile.cache is not installed.""" + return NullRegion() + + +_dogpile_no_value_stub: object = NO_VALUE +"""Stub NO_VALUE sentinel when dogpile.cache is not installed.""" + + +# Try to import real dogpile.cache implementation at runtime +_make_region: MakeRegionFunc +_dogpile_no_value: object try: - from dogpile.cache import make_region as _make_region - from dogpile.cache.api import NO_VALUE as DOGPILE_CACHE_NO_VALUE + from dogpile.cache import ( # type: ignore[import-not-found] # pyright: ignore[reportMissingImports] + make_region as _make_region_real, # pyright: ignore[reportUnknownVariableType] + ) + from dogpile.cache.api import ( # type: ignore[import-not-found] # pyright: ignore[reportMissingImports] + NO_VALUE as _dogpile_no_value_real, # noqa: N811 # pyright: ignore[reportUnknownVariableType] + ) - _dogpile_cache_installed = True - _dogpile_no_value = DOGPILE_CACHE_NO_VALUE -except ImportError: - pass + _make_region = cast("MakeRegionFunc", _make_region_real) + _dogpile_no_value = cast("object", _dogpile_no_value_real) + DOGPILE_CACHE_INSTALLED = True # pyright: ignore[reportConstantRedefinition] -DOGPILE_CACHE_INSTALLED: bool = _dogpile_cache_installed -"""Whether dogpile.cache is installed and available.""" +except ImportError: # pragma: no cover + _make_region = _make_region_stub + _dogpile_no_value = _dogpile_no_value_stub + DOGPILE_CACHE_INSTALLED = False # pyright: ignore[reportConstantRedefinition] -DOGPILE_NO_VALUE: Any = _dogpile_no_value +DOGPILE_NO_VALUE: object = _dogpile_no_value """Sentinel value indicating a cache miss (from dogpile or NullRegion).""" @@ -126,7 +143,7 @@ def __init__(self, config: "CacheConfig") -> None: # Model version tokens are stored in-cache for cross-process consistency. # Use a random token per commit to avoid lost updates without requiring # backend-specific atomic increment support. - self._region: Optional[Union["CacheRegion", NullRegion]] = None # noqa: UP037 + self._region: Optional[SyncCacheRegionProtocol] = None self._model_versions: dict[str, str] = {} self._async_inflight: dict[str, asyncio.Task[Any]] = {} self._async_inflight_lock: Optional[asyncio.Lock] = None @@ -141,7 +158,7 @@ def _inflight_lock_async(self) -> asyncio.Lock: return self._async_inflight_lock @property - def region(self) -> Union["CacheRegion", NullRegion]: + def region(self) -> SyncCacheRegionProtocol: """Get the cache region, creating it if necessary. The region is lazily initialized on first access. If dogpile.cache @@ -155,7 +172,7 @@ def region(self) -> Union["CacheRegion", NullRegion]: self._region = self._create_region() return self._region - def _create_region(self) -> Union["CacheRegion", NullRegion]: # noqa: PLR0911 + def _create_region(self) -> SyncCacheRegionProtocol: """Create and configure the dogpile.cache region. Returns: @@ -164,7 +181,7 @@ def _create_region(self) -> Union["CacheRegion", NullRegion]: # noqa: PLR0911 """ if self.config.region_factory is not None: try: - created_region = cast("Union[CacheRegion, NullRegion]", self.config.region_factory(self.config)) + created_region = cast("SyncCacheRegionProtocol", self.config.region_factory(self.config)) except Exception: logger.exception("Failed to construct cache region via region_factory, using NullRegion") return NullRegion() @@ -181,9 +198,7 @@ def _create_region(self) -> Union["CacheRegion", NullRegion]: # noqa: PLR0911 return NullRegion() try: - if _make_region is None: # pragma: no cover - return NullRegion() - region: "CacheRegion" = _make_region().configure( # noqa: UP037 + region: SyncCacheRegionProtocol = _make_region().configure( self.config.backend, expiration_time=self.config.expiration_time, arguments=self.config.arguments, diff --git a/advanced_alchemy/repository/_async.py b/advanced_alchemy/repository/_async.py index b06843268..4a6a56362 100644 --- a/advanced_alchemy/repository/_async.py +++ b/advanced_alchemy/repository/_async.py @@ -67,6 +67,8 @@ if TYPE_CHECKING: from sqlalchemy.engine.interfaces import _CoreSingleExecuteParams # pyright: ignore[reportPrivateUsage] + from advanced_alchemy.cache.manager import CacheManager + DEFAULT_INSERTMANYVALUES_MAX_PARAMETERS: Final = 950 POSTGRES_VERSION_SUPPORTING_MERGE: Final = 15 DEFAULT_SAFE_TYPES: Final[set[type[Any]]] = { @@ -588,6 +590,8 @@ class SQLAlchemyAsyncRepository(SQLAlchemyAsyncRepositoryProtocol[ModelT], Filte count_with_window_function: bool = True """Use an analytical window function to count results. This allows the count to be performed in a single query. """ + _cache_manager: Optional["CacheManager"] = None + """Cache manager instance for repository-level caching. Set via ``cache_manager`` kwarg or retrieved from ``session.info``.""" def __init__( self, @@ -604,6 +608,7 @@ def __init__( wrap_exceptions: bool = True, uniquify: Optional[bool] = None, count_with_window_function: Optional[bool] = None, + cache_manager: Optional["CacheManager"] = None, **kwargs: Any, ) -> None: """Repository for SQLAlchemy models. @@ -621,6 +626,7 @@ def __init__( wrap_exceptions: Wrap SQLAlchemy exceptions in a ``RepositoryError``. When set to ``False``, the original exception will be raised. uniquify: Optionally apply the ``unique()`` method to results before returning. count_with_window_function: When false, list and count will use two queries instead of an analytical window function. + cache_manager: Optional cache manager for repository-level caching. If not provided, retrieved from ``session.info``. **kwargs: Additional arguments. """ @@ -647,6 +653,8 @@ def __init__( self.statement = select(self.model_type) if statement is None else statement self._dialect = self.session.bind.dialect if self.session.bind is not None else self.session.get_bind().dialect self._prefer_any = any(self._dialect.name == engine_type for engine_type in self.prefer_any_dialects or ()) + # Cache manager: from explicit param or session.info (set by SQLAlchemyAsyncConfig) + self._cache_manager = cache_manager if cache_manager is not None else session.info.get("cache_manager") def _get_uniquify(self, uniquify: Optional[bool] = None) -> bool: """Get the uniquify value, preferring the method parameter over instance setting. diff --git a/advanced_alchemy/repository/_sync.py b/advanced_alchemy/repository/_sync.py index 5870c570b..a79717c1b 100644 --- a/advanced_alchemy/repository/_sync.py +++ b/advanced_alchemy/repository/_sync.py @@ -68,6 +68,8 @@ if TYPE_CHECKING: from sqlalchemy.engine.interfaces import _CoreSingleExecuteParams # pyright: ignore[reportPrivateUsage] + from advanced_alchemy.cache.manager import CacheManager + DEFAULT_INSERTMANYVALUES_MAX_PARAMETERS: Final = 950 POSTGRES_VERSION_SUPPORTING_MERGE: Final = 15 DEFAULT_SAFE_TYPES: Final[set[type[Any]]] = { @@ -589,6 +591,8 @@ class SQLAlchemySyncRepository(SQLAlchemySyncRepositoryProtocol[ModelT], Filtera count_with_window_function: bool = True """Use an analytical window function to count results. This allows the count to be performed in a single query. """ + _cache_manager: Optional["CacheManager"] = None + """Cache manager instance for repository-level caching. Set via ``cache_manager`` kwarg or retrieved from ``session.info``.""" def __init__( self, @@ -605,6 +609,7 @@ def __init__( wrap_exceptions: bool = True, uniquify: Optional[bool] = None, count_with_window_function: Optional[bool] = None, + cache_manager: Optional["CacheManager"] = None, **kwargs: Any, ) -> None: """Repository for SQLAlchemy models. @@ -622,6 +627,7 @@ def __init__( wrap_exceptions: Wrap SQLAlchemy exceptions in a ``RepositoryError``. When set to ``False``, the original exception will be raised. uniquify: Optionally apply the ``unique()`` method to results before returning. count_with_window_function: When false, list and count will use two queries instead of an analytical window function. + cache_manager: Optional cache manager for repository-level caching. If not provided, retrieved from ``session.info``. **kwargs: Additional arguments. """ @@ -648,6 +654,8 @@ def __init__( self.statement = select(self.model_type) if statement is None else statement self._dialect = self.session.bind.dialect if self.session.bind is not None else self.session.get_bind().dialect self._prefer_any = any(self._dialect.name == engine_type for engine_type in self.prefer_any_dialects or ()) + # Cache manager: from explicit param or session.info (set by SQLAlchemyAsyncConfig) + self._cache_manager = cache_manager if cache_manager is not None else session.info.get("cache_manager") def _get_uniquify(self, uniquify: Optional[bool] = None) -> bool: """Get the uniquify value, preferring the method parameter over instance setting. From 7b30c7b6f6ca0f95fbfd29ae6767483f372fb15a Mon Sep 17 00:00:00 2001 From: Cody Fincher Date: Sun, 18 Jan 2026 04:41:53 +0000 Subject: [PATCH 05/10] feat: Add `bind_group` parameter to various repository and service methods. --- advanced_alchemy/_listeners.py | 17 +- advanced_alchemy/cache/manager.py | 40 ++++- advanced_alchemy/repository/_async.py | 179 ++++++++++++++++--- advanced_alchemy/repository/_sync.py | 175 +++++++++++++++--- advanced_alchemy/repository/memory/_async.py | 11 ++ advanced_alchemy/repository/memory/_sync.py | 11 ++ advanced_alchemy/service/_async.py | 36 ++++ advanced_alchemy/service/_sync.py | 36 ++++ 8 files changed, 439 insertions(+), 66 deletions(-) diff --git a/advanced_alchemy/_listeners.py b/advanced_alchemy/_listeners.py index e4cf977e9..50e088915 100644 --- a/advanced_alchemy/_listeners.py +++ b/advanced_alchemy/_listeners.py @@ -468,10 +468,10 @@ class CacheInvalidationTracker: def __init__(self, cache_manager: "CacheManager") -> None: self._cache_manager = cache_manager - self._pending_invalidations: list[tuple[str, Any]] = [] + self._pending_invalidations: list[tuple[str, Any, Optional[str]]] = [] self._pending_model_bumps: set[str] = set() - def add_invalidation(self, model_name: str, entity_id: Any) -> None: + def add_invalidation(self, model_name: str, entity_id: Any, bind_group: Optional[str] = None) -> None: """Queue an entity for cache invalidation. The actual invalidation and model version bump are deferred until @@ -480,8 +480,11 @@ def add_invalidation(self, model_name: str, entity_id: Any) -> None: Args: model_name: The model/table name. entity_id: The entity's primary key value. + bind_group: Optional routing group for multi-master configurations. + When provided, only the cache entry for that bind_group is + invalidated. """ - self._pending_invalidations.append((model_name, entity_id)) + self._pending_invalidations.append((model_name, entity_id, bind_group)) # Queue model version bump for list query invalidation (deferred to commit) self._pending_model_bumps.add(model_name) @@ -493,8 +496,8 @@ def commit(self) -> None: self._pending_model_bumps.clear() # Then invalidate individual entities - for model_name, entity_id in self._pending_invalidations: - self._cache_manager.invalidate_entity_sync(model_name, entity_id) + for model_name, entity_id, bind_group in self._pending_invalidations: + self._cache_manager.invalidate_entity_sync(model_name, entity_id, bind_group) self._pending_invalidations.clear() def rollback(self) -> None: @@ -514,8 +517,8 @@ async def commit_async(self) -> None: self._pending_model_bumps.clear() # Then invalidate individual entities - for model_name, entity_id in self._pending_invalidations: - await self._cache_manager.invalidate_entity_async(model_name, entity_id) + for model_name, entity_id, bind_group in self._pending_invalidations: + await self._cache_manager.invalidate_entity_async(model_name, entity_id, bind_group) self._pending_invalidations.clear() diff --git a/advanced_alchemy/cache/manager.py b/advanced_alchemy/cache/manager.py index bfd4a1c37..e53e43c02 100644 --- a/advanced_alchemy/cache/manager.py +++ b/advanced_alchemy/cache/manager.py @@ -312,6 +312,7 @@ def get_entity_sync( model_name: str, entity_id: Any, model_class: type[T], + bind_group: Optional[str] = None, ) -> Optional[T]: """Get a cached entity by model name and ID (sync). @@ -319,11 +320,14 @@ def get_entity_sync( model_name: The model/table name. entity_id: The entity's primary key value. model_class: The SQLAlchemy model class for deserialization. + bind_group: Optional routing group for multi-master configurations. + When provided, entity caches are namespaced by bind_group to + prevent data leaks between database shards/replicas. Returns: The cached model instance or None if not found. """ - key = f"{model_name}:get:{entity_id}" + key = f"{model_name}:{bind_group}:get:{entity_id}" if bind_group else f"{model_name}:get:{entity_id}" cached = self.get_sync(key) if cached is DOGPILE_NO_VALUE or cached is NO_VALUE: @@ -349,6 +353,7 @@ def set_entity_sync( model_name: str, entity_id: Any, entity: Any, + bind_group: Optional[str] = None, ) -> None: """Cache an entity (sync). @@ -356,8 +361,11 @@ def set_entity_sync( model_name: The model/table name. entity_id: The entity's primary key value. entity: The SQLAlchemy model instance to cache. + bind_group: Optional routing group for multi-master configurations. + When provided, entity caches are namespaced by bind_group to + prevent data leaks between database shards/replicas. """ - key = f"{model_name}:get:{entity_id}" + key = f"{model_name}:{bind_group}:get:{entity_id}" if bind_group else f"{model_name}:get:{entity_id}" serializer = self.config.serializer or default_serializer try: @@ -366,7 +374,7 @@ def set_entity_sync( except Exception: logger.exception("Failed to serialize entity %s:%s", model_name, entity_id) - def invalidate_entity_sync(self, model_name: str, entity_id: Any) -> None: + def invalidate_entity_sync(self, model_name: str, entity_id: Any, bind_group: Optional[str] = None) -> None: """Invalidate the cache for a specific entity (sync). This should be called after an entity is created, updated, or deleted @@ -375,10 +383,13 @@ def invalidate_entity_sync(self, model_name: str, entity_id: Any) -> None: Args: model_name: The model/table name. entity_id: The entity's primary key value. + bind_group: Optional routing group for multi-master configurations. + When provided, only the cache entry for that bind_group is + invalidated. """ - key = f"{model_name}:get:{entity_id}" + key = f"{model_name}:{bind_group}:get:{entity_id}" if bind_group else f"{model_name}:get:{entity_id}" self.delete_sync(key) - logger.debug("Invalidated cache for %s:%s", model_name, entity_id) + logger.debug("Invalidated cache for %s:%s (bind_group=%s)", model_name, entity_id, bind_group) def bump_model_version_sync(self, model_name: str) -> str: """Bump the version token for a model (sync). @@ -621,6 +632,7 @@ async def get_entity_async( model_name: str, entity_id: Any, model_class: type[T], + bind_group: Optional[str] = None, ) -> Optional[T]: """Get a cached entity by model name and ID (async). @@ -628,17 +640,21 @@ async def get_entity_async( model_name: The model/table name. entity_id: The entity's primary key value. model_class: The SQLAlchemy model class for deserialization. + bind_group: Optional routing group for multi-master configurations. + When provided, entity caches are namespaced by bind_group to + prevent data leaks between database shards/replicas. Returns: The cached model instance or None if not found. """ - return await async_(self.get_entity_sync)(model_name, entity_id, model_class) + return await async_(self.get_entity_sync)(model_name, entity_id, model_class, bind_group) async def set_entity_async( self, model_name: str, entity_id: Any, entity: Any, + bind_group: Optional[str] = None, ) -> None: """Cache an entity (async). @@ -646,10 +662,13 @@ async def set_entity_async( model_name: The model/table name. entity_id: The entity's primary key value. entity: The SQLAlchemy model instance to cache. + bind_group: Optional routing group for multi-master configurations. + When provided, entity caches are namespaced by bind_group to + prevent data leaks between database shards/replicas. """ - await async_(self.set_entity_sync)(model_name, entity_id, entity) + await async_(self.set_entity_sync)(model_name, entity_id, entity, bind_group) - async def invalidate_entity_async(self, model_name: str, entity_id: Any) -> None: + async def invalidate_entity_async(self, model_name: str, entity_id: Any, bind_group: Optional[str] = None) -> None: """Invalidate the cache for a specific entity (async). This should be called after an entity is created, updated, or deleted @@ -658,8 +677,11 @@ async def invalidate_entity_async(self, model_name: str, entity_id: Any) -> None Args: model_name: The model/table name. entity_id: The entity's primary key value. + bind_group: Optional routing group for multi-master configurations. + When provided, only the cache entry for that bind_group is + invalidated. """ - await async_(self.invalidate_entity_sync)(model_name, entity_id) + await async_(self.invalidate_entity_sync)(model_name, entity_id, bind_group) async def bump_model_version_async(self, model_name: str) -> str: """Bump the version token for a model (async). diff --git a/advanced_alchemy/repository/_async.py b/advanced_alchemy/repository/_async.py index 4a6a56362..3f353cf9a 100644 --- a/advanced_alchemy/repository/_async.py +++ b/advanced_alchemy/repository/_async.py @@ -264,6 +264,7 @@ async def add( auto_expunge: Optional[bool] = None, auto_refresh: Optional[bool] = None, error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, + bind_group: Optional[str] = None, ) -> ModelT: ... async def add_many( @@ -273,6 +274,7 @@ async def add_many( auto_commit: Optional[bool] = None, auto_expunge: Optional[bool] = None, error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, + bind_group: Optional[str] = None, ) -> Sequence[ModelT]: ... async def delete( @@ -285,6 +287,7 @@ async def delete( error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, + bind_group: Optional[str] = None, ) -> ModelT: ... async def delete_many( @@ -298,6 +301,7 @@ async def delete_many( error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, + bind_group: Optional[str] = None, ) -> Sequence[ModelT]: ... async def delete_where( @@ -309,6 +313,7 @@ async def delete_where( error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, execution_options: Optional[dict[str, Any]] = None, sanity_check: bool = True, + bind_group: Optional[str] = None, **kwargs: Any, ) -> Sequence[ModelT]: ... @@ -375,6 +380,7 @@ async def get_or_upsert( error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, + bind_group: Optional[str] = None, **kwargs: Any, ) -> tuple[ModelT, bool]: ... @@ -390,6 +396,7 @@ async def get_and_update( error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, + bind_group: Optional[str] = None, **kwargs: Any, ) -> tuple[ModelT, bool]: ... @@ -417,6 +424,7 @@ async def update( error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, + bind_group: Optional[str] = None, ) -> ModelT: ... async def update_many( @@ -428,6 +436,7 @@ async def update_many( error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, + bind_group: Optional[str] = None, ) -> list[ModelT]: ... def _get_update_many_statement( @@ -451,6 +460,7 @@ async def upsert( error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, + bind_group: Optional[str] = None, ) -> ModelT: ... async def upsert_many( @@ -464,6 +474,7 @@ async def upsert_many( error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, + bind_group: Optional[str] = None, ) -> list[ModelT]: ... async def list_and_count( @@ -592,6 +603,8 @@ class SQLAlchemyAsyncRepository(SQLAlchemyAsyncRepositoryProtocol[ModelT], Filte """ _cache_manager: Optional["CacheManager"] = None """Cache manager instance for repository-level caching. Set via ``cache_manager`` kwarg or retrieved from ``session.info``.""" + _bind_group: Optional[str] = None + """Default bind group for routing operations (e.g., to read replicas). Can be overridden per-method.""" def __init__( self, @@ -609,6 +622,7 @@ def __init__( uniquify: Optional[bool] = None, count_with_window_function: Optional[bool] = None, cache_manager: Optional["CacheManager"] = None, + bind_group: Optional[str] = None, **kwargs: Any, ) -> None: """Repository for SQLAlchemy models. @@ -627,6 +641,7 @@ def __init__( uniquify: Optionally apply the ``unique()`` method to results before returning. count_with_window_function: When false, list and count will use two queries instead of an analytical window function. cache_manager: Optional cache manager for repository-level caching. If not provided, retrieved from ``session.info``. + bind_group: Optional default routing group to use for all operations. Can be overridden per-method. **kwargs: Additional arguments. """ @@ -655,6 +670,8 @@ def __init__( self._prefer_any = any(self._dialect.name == engine_type for engine_type in self.prefer_any_dialects or ()) # Cache manager: from explicit param or session.info (set by SQLAlchemyAsyncConfig) self._cache_manager = cache_manager if cache_manager is not None else session.info.get("cache_manager") + # Default bind group for all operations (can be overridden per-method) + self._bind_group = bind_group def _get_uniquify(self, uniquify: Optional[bool] = None) -> bool: """Get the uniquify value, preferring the method parameter over instance setting. @@ -667,7 +684,18 @@ def _get_uniquify(self, uniquify: Optional[bool] = None) -> bool: """ return bool(uniquify) if uniquify is not None else self._uniquify - def _queue_cache_invalidation(self, entity_id: Any) -> None: + def _resolve_bind_group(self, bind_group: Optional[str] = None) -> Optional[str]: + """Resolve the bind_group to use, preferring method parameter over instance default. + + Args: + bind_group: Optional override for the bind_group setting. + + Returns: + The bind_group to use, or None if not set. + """ + return bind_group if bind_group is not None else self._bind_group + + def _queue_cache_invalidation(self, entity_id: Any, bind_group: Optional[str] = None) -> None: """Queue a cache invalidation for an entity. The invalidation will be processed after the transaction commits. @@ -678,6 +706,9 @@ def _queue_cache_invalidation(self, entity_id: Any) -> None: Args: entity_id: The primary key value of the entity to invalidate. + bind_group: Optional routing group for multi-master configurations. + When provided, only the cache entry for that bind_group is + invalidated. """ if self._cache_manager is not None: from advanced_alchemy._listeners import get_cache_tracker @@ -685,7 +716,7 @@ def _queue_cache_invalidation(self, entity_id: Any) -> None: model_name = cast("str", self.model_type.__tablename__) # type: ignore[attr-defined] tracker = get_cache_tracker(self.session, self._cache_manager) if tracker is not None: - tracker.add_invalidation(model_name, entity_id) + tracker.add_invalidation(model_name, entity_id, bind_group) def _type_must_use_in_instead_of_any(self, matched_values: "list[Any]", field_type: "Any" = None) -> bool: """Determine if field.in_() should be used instead of any_() for compatibility. @@ -854,6 +885,7 @@ async def add( auto_expunge: Optional[bool] = None, auto_refresh: Optional[bool] = None, error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, + bind_group: Optional[str] = None, ) -> ModelT: """Add ``data`` to the collection. @@ -864,10 +896,12 @@ async def add( auto_commit: Commit objects before returning. error_messages: An optional dictionary of templates to use for friendlier error messages to clients + bind_group: Optional routing group for multi-master configurations. Returns: The added instance. """ + _ = bind_group # Reserved for future multi-master routing error_messages = self._get_error_messages( error_messages=error_messages, default_messages=self.error_messages, @@ -888,6 +922,7 @@ async def add_many( auto_commit: Optional[bool] = None, auto_expunge: Optional[bool] = None, error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, + bind_group: Optional[str] = None, ) -> Sequence[ModelT]: """Add many `data` to the collection. @@ -897,10 +932,12 @@ async def add_many( auto_commit: Commit objects before returning. error_messages: An optional dictionary of templates to use for friendlier error messages to clients + bind_group: Optional routing group for multi-master configurations. Returns: The added instances. """ + _ = bind_group # Reserved for future multi-master routing error_messages = self._get_error_messages( error_messages=error_messages, default_messages=self.error_messages, @@ -925,6 +962,7 @@ async def delete( load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, + bind_group: Optional[str] = None, ) -> ModelT: """Delete instance identified by ``item_id``. @@ -939,6 +977,7 @@ async def delete( load: Set default relationships to be loaded execution_options: Set default execution options uniquify: Optionally apply the ``unique()`` method to results before returning. + bind_group: Optional routing group for multi-master configurations. Returns: The deleted instance. @@ -952,12 +991,17 @@ async def delete( with wrap_sqlalchemy_exception( error_messages=error_messages, dialect_name=self._dialect.name, wrap_exceptions=self.wrap_exceptions ): + resolved_bind_group = self._resolve_bind_group(bind_group) + if resolved_bind_group: + execution_options = dict(execution_options) if execution_options else {} + execution_options["bind_group"] = resolved_bind_group execution_options = self._get_execution_options(execution_options) instance = await self.get( item_id, id_attribute=id_attribute, load=load, execution_options=execution_options, + bind_group=bind_group, ) await self.session.delete(instance) await self._flush_or_commit(auto_commit=auto_commit) @@ -976,6 +1020,7 @@ async def delete_many( load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, + bind_group: Optional[str] = None, ) -> Sequence[ModelT]: """Delete instance identified by `item_id`. @@ -992,6 +1037,7 @@ async def delete_many( load: Set default relationships to be loaded execution_options: Set default execution options uniquify: Optionally apply the ``unique()`` method to results before returning. + bind_group: Optional routing group for multi-master configurations. Returns: The deleted instances. @@ -1005,6 +1051,10 @@ async def delete_many( with wrap_sqlalchemy_exception( error_messages=error_messages, dialect_name=self._dialect.name, wrap_exceptions=self.wrap_exceptions ): + resolved_bind_group = self._resolve_bind_group(bind_group) + if resolved_bind_group: + execution_options = dict(execution_options) if execution_options else {} + execution_options["bind_group"] = resolved_bind_group execution_options = self._get_execution_options(execution_options) loader_options, _loader_options_have_wildcard = self._get_loader_options(load) id_attribute = get_instrumented_attr( @@ -1075,6 +1125,7 @@ async def delete_where( load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, + bind_group: Optional[str] = None, **kwargs: Any, ) -> Sequence[ModelT]: """Delete instances specified by referenced kwargs and filters. @@ -1089,6 +1140,7 @@ async def delete_where( load: Set default relationships to be loaded execution_options: Set default execution options uniquify: Optionally apply the ``unique()`` method to results before returning. + bind_group: Optional routing group for multi-master configurations. **kwargs: Arguments to apply to a delete Raises: @@ -1106,6 +1158,10 @@ async def delete_where( with wrap_sqlalchemy_exception( error_messages=error_messages, dialect_name=self._dialect.name, wrap_exceptions=self.wrap_exceptions ): + resolved_bind_group = self._resolve_bind_group(bind_group) + if resolved_bind_group: + execution_options = dict(execution_options) if execution_options else {} + execution_options["bind_group"] = resolved_bind_group execution_options = self._get_execution_options(execution_options) loader_options, _loader_options_have_wildcard = self._get_loader_options(load) model_type = self.model_type @@ -1127,6 +1183,7 @@ async def delete_where( execution_options=execution_options, auto_expunge=auto_expunge, use_cache=False, # Always fetch from DB for delete_where + bind_group=bind_group, **kwargs, ), ) @@ -1142,7 +1199,7 @@ async def delete_where( for instance in instances: self._expunge(instance, auto_expunge=auto_expunge) # Queue cache invalidation (processed on commit) - self._queue_cache_invalidation(self.get_id_attribute_value(instance)) + self._queue_cache_invalidation(self.get_id_attribute_value(instance), resolved_bind_group) return instances async def exists( @@ -1260,7 +1317,7 @@ def _get_delete_many_statement( return statement.where(id_attribute.in_(id_chunk)) # pyright: ignore[reportUnknownMemberType,reportUnknownVariableType] return statement.where(any_(id_chunk) == id_attribute) # type: ignore[arg-type] - async def _get_uncached_impl( + async def _get_from_db( self, item_id: Any, *, @@ -1271,11 +1328,16 @@ async def _get_uncached_impl( load: Optional[LoadSpec], execution_options: Optional[dict[str, Any]], with_for_update: ForUpdateParameter, + bind_group: Optional[str] = None, ) -> ModelT: """Fetch an entity from the database without using cache.""" with wrap_sqlalchemy_exception( error_messages=error_messages, dialect_name=self._dialect.name, wrap_exceptions=self.wrap_exceptions ): + resolved_bind_group = self._resolve_bind_group(bind_group) + if resolved_bind_group: + execution_options = dict(execution_options) if execution_options else {} + execution_options["bind_group"] = resolved_bind_group resolved_execution_options = self._get_execution_options(execution_options) resolved_statement = self.statement if statement is None else statement loader_options, loader_options_have_wildcard = self._get_loader_options(load) @@ -1306,10 +1368,11 @@ async def _get_cached_creator( load: Optional[LoadSpec], execution_options: Optional[dict[str, Any]], with_for_update: ForUpdateParameter, + bind_group: Optional[str] = None, ) -> ModelT: """Singleflight creator for get(id) caching (async).""" if self._cache_manager is None: - return await self._get_uncached_impl( + return await self._get_from_db( item_id, auto_expunge=auto_expunge, statement=statement, @@ -1318,13 +1381,16 @@ async def _get_cached_creator( load=load, execution_options=execution_options, with_for_update=with_for_update, + bind_group=bind_group, ) - existing = await self._cache_manager.get_entity_async(model_name, item_id, self.model_type) + existing = await self._cache_manager.get_entity_async( + model_name, item_id, self.model_type, bind_group=bind_group + ) if existing is not None: return existing - instance = await self._get_uncached_impl( + instance = await self._get_from_db( item_id, auto_expunge=auto_expunge, statement=statement, @@ -1333,11 +1399,12 @@ async def _get_cached_creator( load=load, execution_options=execution_options, with_for_update=with_for_update, + bind_group=bind_group, ) - await self._cache_manager.set_entity_async(model_name, item_id, instance) + await self._cache_manager.set_entity_async(model_name, item_id, instance, bind_group=bind_group) return instance - async def _list_uncached_impl( + async def _list_from_db( self, *, filters: Sequence[Union[StatementFilter, ColumnElement[bool]]], @@ -1349,12 +1416,17 @@ async def _list_uncached_impl( execution_options: Optional[dict[str, Any]], kwargs: dict[str, Any], uniquify: Optional[bool], + bind_group: Optional[str] = None, ) -> list[ModelT]: """Fetch a list of entities from the database without using cache.""" self._uniquify = self._get_uniquify(uniquify) with wrap_sqlalchemy_exception( error_messages=error_messages, dialect_name=self._dialect.name, wrap_exceptions=self.wrap_exceptions ): + resolved_bind_group = self._resolve_bind_group(bind_group) + if resolved_bind_group: + execution_options = dict(execution_options) if execution_options else {} + execution_options["bind_group"] = resolved_bind_group resolved_execution_options = self._get_execution_options(execution_options) resolved_statement = self.statement if statement is None else statement loader_options, loader_options_have_wildcard = self._get_loader_options(load) @@ -1387,10 +1459,11 @@ async def _list_cached_creator( execution_options: Optional[dict[str, Any]], kwargs: dict[str, Any], uniquify: Optional[bool], + bind_group: Optional[str] = None, ) -> list[ModelT]: """Singleflight creator for list caching (async).""" if self._cache_manager is None: - return await self._list_uncached_impl( + return await self._list_from_db( filters=filters, auto_expunge=auto_expunge, statement=statement, @@ -1400,13 +1473,14 @@ async def _list_cached_creator( execution_options=execution_options, kwargs=kwargs, uniquify=uniquify, + bind_group=bind_group, ) existing = await self._cache_manager.get_list_async(cache_key, self.model_type) if existing is not None: return existing - instances = await self._list_uncached_impl( + instances = await self._list_from_db( filters=filters, auto_expunge=auto_expunge, statement=statement, @@ -1416,11 +1490,12 @@ async def _list_cached_creator( execution_options=execution_options, kwargs=kwargs, uniquify=uniquify, + bind_group=bind_group, ) await self._cache_manager.set_list_async(cache_key, list(instances)) return list(instances) - async def _list_and_count_uncached_impl( + async def _list_and_count_from_db( self, *, filters: Sequence[Union[StatementFilter, ColumnElement[bool]]], @@ -1433,9 +1508,14 @@ async def _list_and_count_uncached_impl( execution_options: Optional[dict[str, Any]], kwargs: dict[str, Any], uniquify: Optional[bool], + bind_group: Optional[str] = None, ) -> tuple[list[ModelT], int]: """Fetch a list+count payload from the database without using cache.""" self._uniquify = self._get_uniquify(uniquify) + resolved_bind_group = self._resolve_bind_group(bind_group) + if resolved_bind_group: + execution_options = dict(execution_options) if execution_options else {} + execution_options["bind_group"] = resolved_bind_group if self._dialect.name in {"spanner", "spanner+spanner"} or not count_with_window_function: return await self._list_and_count_basic( *filters, @@ -1472,10 +1552,11 @@ async def _list_and_count_cached_creator( execution_options: Optional[dict[str, Any]], kwargs: dict[str, Any], uniquify: Optional[bool], + bind_group: Optional[str] = None, ) -> tuple[list[ModelT], int]: """Singleflight creator for list_and_count caching (async).""" if self._cache_manager is None: - return await self._list_and_count_uncached_impl( + return await self._list_and_count_from_db( filters=filters, auto_expunge=auto_expunge, statement=statement, @@ -1486,13 +1567,14 @@ async def _list_and_count_cached_creator( execution_options=execution_options, kwargs=kwargs, uniquify=uniquify, + bind_group=bind_group, ) existing = await self._cache_manager.get_list_and_count_async(cache_key, self.model_type) if existing is not None: return existing - instances, count = await self._list_and_count_uncached_impl( + instances, count = await self._list_and_count_from_db( filters=filters, auto_expunge=auto_expunge, statement=statement, @@ -1503,6 +1585,7 @@ async def _list_and_count_cached_creator( execution_options=execution_options, kwargs=kwargs, uniquify=uniquify, + bind_group=bind_group, ) await self._cache_manager.set_list_and_count_async(cache_key, list(instances), count) return list(instances), count @@ -1554,6 +1637,9 @@ async def get( resolved_id_attribute = resolved_id_attribute.key cache_manager = self._cache_manager + # Resolve bind_group for cache key namespacing + resolved_bind_group = self._resolve_bind_group(bind_group) + if ( use_cache and cache_manager is not None @@ -1567,12 +1653,20 @@ async def get( and execution_options is None ): model_name = cast("str", self.model_type.__tablename__) # type: ignore[attr-defined] - cached = await cache_manager.get_entity_async(model_name, item_id, self.model_type) + cached = await cache_manager.get_entity_async( + model_name, item_id, self.model_type, bind_group=resolved_bind_group + ) if cached is not None: return cached + # Include bind_group in singleflight key to prevent cross-shard cache pollution + singleflight_key = ( + f"{model_name}:{resolved_bind_group}:get:{item_id}" + if resolved_bind_group + else f"{model_name}:get:{item_id}" + ) return await cache_manager.singleflight_async( - f"{model_name}:get:{item_id}", + singleflight_key, partial( self._get_cached_creator, model_name, @@ -1584,10 +1678,11 @@ async def get( load=load, execution_options=execution_options, with_for_update=with_for_update, + bind_group=resolved_bind_group, ), ) - return await self._get_uncached_impl( + return await self._get_from_db( item_id, auto_expunge=auto_expunge, statement=statement, @@ -1596,6 +1691,7 @@ async def get( load=load, execution_options=execution_options, with_for_update=with_for_update, + bind_group=bind_group, ) async def get_one( @@ -1732,6 +1828,7 @@ async def get_or_upsert( load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, + bind_group: Optional[str] = None, **kwargs: Any, ) -> tuple[ModelT, bool]: """Get instance identified by ``kwargs`` or create if it doesn't exist. @@ -1755,6 +1852,7 @@ async def get_or_upsert( load: Set relationships to be loaded execution_options: Set default execution options uniquify: Optionally apply the ``unique()`` method to results before returning. + bind_group: Optional routing group for multi-master configurations. **kwargs: Identifier of the instance to be retrieved. Returns: @@ -1783,6 +1881,7 @@ async def get_or_upsert( **match_filter, load=load, execution_options=execution_options, + bind_group=bind_group, ) if not existing: return ( @@ -1791,6 +1890,7 @@ async def get_or_upsert( auto_commit=auto_commit, auto_expunge=auto_expunge, auto_refresh=auto_refresh, + bind_group=bind_group, ), True, ) @@ -1823,6 +1923,7 @@ async def get_and_update( load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, + bind_group: Optional[str] = None, **kwargs: Any, ) -> tuple[ModelT, bool]: """Get instance identified by ``kwargs`` and update the model if the arguments are different. @@ -1844,6 +1945,7 @@ async def get_and_update( load: Set relationships to be loaded execution_options: Set default execution options uniquify: Optionally apply the ``unique()`` method to results before returning. + bind_group: Optional routing group for multi-master configurations. **kwargs: Identifier of the instance to be retrieved. Returns: @@ -1867,7 +1969,9 @@ async def get_and_update( } else: match_filter = kwargs - existing = await self.get_one(*filters, **match_filter, load=load, execution_options=execution_options) + existing = await self.get_one( + *filters, **match_filter, load=load, execution_options=execution_options, bind_group=bind_group + ) updated = False for field_name, new_field_value in kwargs.items(): field = getattr(existing, field_name, MISSING) @@ -1955,6 +2059,7 @@ async def update( load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, + bind_group: Optional[str] = None, ) -> ModelT: """Update instance with the attribute values present on `data`. @@ -1976,6 +2081,7 @@ async def update( load: Set relationships to be loaded execution_options: Set default execution options uniquify: Optionally apply the ``unique()`` method to results before returning. + bind_group: Optional routing group for multi-master configurations. Returns: The updated instance. @@ -1998,6 +2104,7 @@ async def update( load=load, execution_options=execution_options, with_for_update=with_for_update, + bind_group=bind_group, ) mapper = None with ( @@ -2071,6 +2178,7 @@ async def update_many( load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, + bind_group: Optional[str] = None, ) -> list[ModelT]: """Update one or more instances with the attribute values present on `data`. @@ -2088,6 +2196,7 @@ async def update_many( load: Set default relationships to be loaded execution_options: Set default execution options uniquify: Optionally apply the ``unique()`` method to results before returning. + bind_group: Optional routing group for multi-master configurations. Returns: The updated instances. @@ -2111,6 +2220,10 @@ async def update_many( with wrap_sqlalchemy_exception( error_messages=error_messages, dialect_name=self._dialect.name, wrap_exceptions=self.wrap_exceptions ): + resolved_bind_group = self._resolve_bind_group(bind_group) + if resolved_bind_group: + execution_options = dict(execution_options) if execution_options else {} + execution_options["bind_group"] = resolved_bind_group execution_options = self._get_execution_options(execution_options) loader_options = self._get_loader_options(load)[0] supports_returning = self._dialect.update_executemany_returning and self._dialect.name != "oracle" @@ -2143,6 +2256,7 @@ async def update_many( getattr(self.model_type, self.id_attribute).in_(updated_ids), load=loader_options, execution_options=execution_options, + bind_group=bind_group, ) for instance in updated_instances: self._expunge(instance, auto_expunge=auto_expunge) @@ -2221,7 +2335,7 @@ async def list_and_count( and load is None and not self._default_loader_options ): - return await self._list_and_count_uncached_impl( + return await self._list_and_count_from_db( filters=filters, auto_expunge=auto_expunge, statement=statement, @@ -2232,6 +2346,7 @@ async def list_and_count( execution_options=execution_options, kwargs=kwargs, uniquify=uniquify, + bind_group=bind_group, ) model_name = cast("str", self.model_type.__tablename__) # type: ignore[attr-defined] @@ -2248,7 +2363,7 @@ async def list_and_count( count_with_window_function=count_with_window_function, ) if cache_key is None: - return await self._list_and_count_uncached_impl( + return await self._list_and_count_from_db( filters=filters, auto_expunge=auto_expunge, statement=statement, @@ -2259,6 +2374,7 @@ async def list_and_count( execution_options=execution_options, kwargs=kwargs, uniquify=uniquify, + bind_group=bind_group, ) cached = await cache_manager.get_list_and_count_async(cache_key, self.model_type) @@ -2280,6 +2396,7 @@ async def list_and_count( execution_options=execution_options, kwargs=kwargs, uniquify=uniquify, + bind_group=bind_group, ), ) @@ -2501,6 +2618,7 @@ async def upsert( load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, + bind_group: Optional[str] = None, ) -> ModelT: """Modify or create instance. @@ -2525,6 +2643,7 @@ async def upsert( load: Set relationships to be loaded execution_options: Set default execution options uniquify: Optionally apply the ``unique()`` method to results before returning. + bind_group: Optional routing group for multi-master configurations. Returns: The updated or created instance. @@ -2544,13 +2663,16 @@ async def upsert( match_filter = {self.id_attribute: getattr(data, self.id_attribute, None)} else: match_filter = data.to_dict(exclude={self.id_attribute}) - existing = await self.get_one_or_none(load=load, execution_options=execution_options, **match_filter) + existing = await self.get_one_or_none( + load=load, execution_options=execution_options, bind_group=bind_group, **match_filter + ) if not existing: return await self.add( data, auto_commit=auto_commit, auto_expunge=auto_expunge, auto_refresh=auto_refresh, + bind_group=bind_group, ) with wrap_sqlalchemy_exception( error_messages=error_messages, dialect_name=self._dialect.name, wrap_exceptions=self.wrap_exceptions @@ -2569,7 +2691,7 @@ async def upsert( ) self._expunge(instance, auto_expunge=auto_expunge) # Queue cache invalidation (processed on commit) - self._queue_cache_invalidation(self.get_id_attribute_value(instance)) + self._queue_cache_invalidation(self.get_id_attribute_value(instance), bind_group) return instance async def upsert_many( @@ -2584,6 +2706,7 @@ async def upsert_many( load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, + bind_group: Optional[str] = None, ) -> list[ModelT]: """Modify or create multiple instances. @@ -2607,6 +2730,7 @@ async def upsert_many( load: Set default relationships to be loaded execution_options: Set default execution options uniquify: Optionally apply the ``unique()`` method to results before returning. + bind_group: Optional routing group to use for the operation. Returns: The updated or created instance. @@ -2641,6 +2765,7 @@ async def upsert_many( load=load, execution_options=execution_options, auto_expunge=False, + bind_group=bind_group, ) for field_name in match_fields: field = get_instrumented_attr(self.model_type, field_name) @@ -2659,7 +2784,7 @@ async def upsert_many( data_to_insert.append(datum) if data_to_insert: instances.extend( - await self.add_many(data_to_insert, auto_commit=False, auto_expunge=False), + await self.add_many(data_to_insert, auto_commit=False, auto_expunge=False, bind_group=bind_group), ) if data_to_update: instances.extend( @@ -2669,6 +2794,7 @@ async def upsert_many( auto_expunge=False, load=load, execution_options=execution_options, + bind_group=bind_group, ), ) await self._flush_or_commit(auto_commit=auto_commit) @@ -2760,7 +2886,7 @@ async def list( and load is None and not self._default_loader_options ): - return await self._list_uncached_impl( + return await self._list_from_db( filters=filters, auto_expunge=auto_expunge, statement=statement, @@ -2770,6 +2896,7 @@ async def list( execution_options=execution_options, kwargs=kwargs, uniquify=uniquify, + bind_group=bind_group, ) model_name = cast("str", self.model_type.__tablename__) # type: ignore[attr-defined] @@ -2785,7 +2912,7 @@ async def list( uniquify=self._uniquify, ) if cache_key is None: - return await self._list_uncached_impl( + return await self._list_from_db( filters=filters, auto_expunge=auto_expunge, statement=statement, @@ -2795,6 +2922,7 @@ async def list( execution_options=execution_options, kwargs=kwargs, uniquify=uniquify, + bind_group=bind_group, ) cached = await cache_manager.get_list_async(cache_key, self.model_type) @@ -2815,6 +2943,7 @@ async def list( execution_options=execution_options, kwargs=kwargs, uniquify=uniquify, + bind_group=bind_group, ), ) diff --git a/advanced_alchemy/repository/_sync.py b/advanced_alchemy/repository/_sync.py index a79717c1b..fa4101846 100644 --- a/advanced_alchemy/repository/_sync.py +++ b/advanced_alchemy/repository/_sync.py @@ -265,6 +265,7 @@ def add( auto_expunge: Optional[bool] = None, auto_refresh: Optional[bool] = None, error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, + bind_group: Optional[str] = None, ) -> ModelT: ... def add_many( @@ -274,6 +275,7 @@ def add_many( auto_commit: Optional[bool] = None, auto_expunge: Optional[bool] = None, error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, + bind_group: Optional[str] = None, ) -> Sequence[ModelT]: ... def delete( @@ -286,6 +288,7 @@ def delete( error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, + bind_group: Optional[str] = None, ) -> ModelT: ... def delete_many( @@ -299,6 +302,7 @@ def delete_many( error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, + bind_group: Optional[str] = None, ) -> Sequence[ModelT]: ... def delete_where( @@ -310,6 +314,7 @@ def delete_where( error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, execution_options: Optional[dict[str, Any]] = None, sanity_check: bool = True, + bind_group: Optional[str] = None, **kwargs: Any, ) -> Sequence[ModelT]: ... @@ -376,6 +381,7 @@ def get_or_upsert( error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, + bind_group: Optional[str] = None, **kwargs: Any, ) -> tuple[ModelT, bool]: ... @@ -391,6 +397,7 @@ def get_and_update( error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, + bind_group: Optional[str] = None, **kwargs: Any, ) -> tuple[ModelT, bool]: ... @@ -418,6 +425,7 @@ def update( error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, + bind_group: Optional[str] = None, ) -> ModelT: ... def update_many( @@ -429,6 +437,7 @@ def update_many( error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, + bind_group: Optional[str] = None, ) -> list[ModelT]: ... def _get_update_many_statement( @@ -452,6 +461,7 @@ def upsert( error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, + bind_group: Optional[str] = None, ) -> ModelT: ... def upsert_many( @@ -465,6 +475,7 @@ def upsert_many( error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, + bind_group: Optional[str] = None, ) -> list[ModelT]: ... def list_and_count( @@ -593,6 +604,8 @@ class SQLAlchemySyncRepository(SQLAlchemySyncRepositoryProtocol[ModelT], Filtera """ _cache_manager: Optional["CacheManager"] = None """Cache manager instance for repository-level caching. Set via ``cache_manager`` kwarg or retrieved from ``session.info``.""" + _bind_group: Optional[str] = None + """Default bind group for routing operations (e.g., to read replicas). Can be overridden per-method.""" def __init__( self, @@ -610,6 +623,7 @@ def __init__( uniquify: Optional[bool] = None, count_with_window_function: Optional[bool] = None, cache_manager: Optional["CacheManager"] = None, + bind_group: Optional[str] = None, **kwargs: Any, ) -> None: """Repository for SQLAlchemy models. @@ -628,6 +642,7 @@ def __init__( uniquify: Optionally apply the ``unique()`` method to results before returning. count_with_window_function: When false, list and count will use two queries instead of an analytical window function. cache_manager: Optional cache manager for repository-level caching. If not provided, retrieved from ``session.info``. + bind_group: Optional default routing group to use for all operations. Can be overridden per-method. **kwargs: Additional arguments. """ @@ -656,6 +671,8 @@ def __init__( self._prefer_any = any(self._dialect.name == engine_type for engine_type in self.prefer_any_dialects or ()) # Cache manager: from explicit param or session.info (set by SQLAlchemyAsyncConfig) self._cache_manager = cache_manager if cache_manager is not None else session.info.get("cache_manager") + # Default bind group for all operations (can be overridden per-method) + self._bind_group = bind_group def _get_uniquify(self, uniquify: Optional[bool] = None) -> bool: """Get the uniquify value, preferring the method parameter over instance setting. @@ -668,7 +685,18 @@ def _get_uniquify(self, uniquify: Optional[bool] = None) -> bool: """ return bool(uniquify) if uniquify is not None else self._uniquify - def _queue_cache_invalidation(self, entity_id: Any) -> None: + def _resolve_bind_group(self, bind_group: Optional[str] = None) -> Optional[str]: + """Resolve the bind_group to use, preferring method parameter over instance default. + + Args: + bind_group: Optional override for the bind_group setting. + + Returns: + The bind_group to use, or None if not set. + """ + return bind_group if bind_group is not None else self._bind_group + + def _queue_cache_invalidation(self, entity_id: Any, bind_group: Optional[str] = None) -> None: """Queue a cache invalidation for an entity. The invalidation will be processed after the transaction commits. @@ -679,6 +707,9 @@ def _queue_cache_invalidation(self, entity_id: Any) -> None: Args: entity_id: The primary key value of the entity to invalidate. + bind_group: Optional routing group for multi-master configurations. + When provided, only the cache entry for that bind_group is + invalidated. """ if self._cache_manager is not None: from advanced_alchemy._listeners import get_cache_tracker @@ -686,7 +717,7 @@ def _queue_cache_invalidation(self, entity_id: Any) -> None: model_name = cast("str", self.model_type.__tablename__) # type: ignore[attr-defined] tracker = get_cache_tracker(self.session, self._cache_manager) if tracker is not None: - tracker.add_invalidation(model_name, entity_id) + tracker.add_invalidation(model_name, entity_id, bind_group) def _type_must_use_in_instead_of_any(self, matched_values: "list[Any]", field_type: "Any" = None) -> bool: """Determine if field.in_() should be used instead of any_() for compatibility. @@ -855,6 +886,7 @@ def add( auto_expunge: Optional[bool] = None, auto_refresh: Optional[bool] = None, error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, + bind_group: Optional[str] = None, ) -> ModelT: """Add ``data`` to the collection. @@ -865,10 +897,12 @@ def add( auto_commit: Commit objects before returning. error_messages: An optional dictionary of templates to use for friendlier error messages to clients + bind_group: Optional routing group for multi-master configurations. Returns: The added instance. """ + _ = bind_group # Reserved for future multi-master routing error_messages = self._get_error_messages( error_messages=error_messages, default_messages=self.error_messages, @@ -889,6 +923,7 @@ def add_many( auto_commit: Optional[bool] = None, auto_expunge: Optional[bool] = None, error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, + bind_group: Optional[str] = None, ) -> Sequence[ModelT]: """Add many `data` to the collection. @@ -898,10 +933,12 @@ def add_many( auto_commit: Commit objects before returning. error_messages: An optional dictionary of templates to use for friendlier error messages to clients + bind_group: Optional routing group for multi-master configurations. Returns: The added instances. """ + _ = bind_group # Reserved for future multi-master routing error_messages = self._get_error_messages( error_messages=error_messages, default_messages=self.error_messages, @@ -926,6 +963,7 @@ def delete( load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, + bind_group: Optional[str] = None, ) -> ModelT: """Delete instance identified by ``item_id``. @@ -940,6 +978,7 @@ def delete( load: Set default relationships to be loaded execution_options: Set default execution options uniquify: Optionally apply the ``unique()`` method to results before returning. + bind_group: Optional routing group for multi-master configurations. Returns: The deleted instance. @@ -953,12 +992,17 @@ def delete( with wrap_sqlalchemy_exception( error_messages=error_messages, dialect_name=self._dialect.name, wrap_exceptions=self.wrap_exceptions ): + resolved_bind_group = self._resolve_bind_group(bind_group) + if resolved_bind_group: + execution_options = dict(execution_options) if execution_options else {} + execution_options["bind_group"] = resolved_bind_group execution_options = self._get_execution_options(execution_options) instance = self.get( item_id, id_attribute=id_attribute, load=load, execution_options=execution_options, + bind_group=bind_group, ) self.session.delete(instance) self._flush_or_commit(auto_commit=auto_commit) @@ -977,6 +1021,7 @@ def delete_many( load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, + bind_group: Optional[str] = None, ) -> Sequence[ModelT]: """Delete instance identified by `item_id`. @@ -993,6 +1038,7 @@ def delete_many( load: Set default relationships to be loaded execution_options: Set default execution options uniquify: Optionally apply the ``unique()`` method to results before returning. + bind_group: Optional routing group for multi-master configurations. Returns: The deleted instances. @@ -1006,6 +1052,10 @@ def delete_many( with wrap_sqlalchemy_exception( error_messages=error_messages, dialect_name=self._dialect.name, wrap_exceptions=self.wrap_exceptions ): + resolved_bind_group = self._resolve_bind_group(bind_group) + if resolved_bind_group: + execution_options = dict(execution_options) if execution_options else {} + execution_options["bind_group"] = resolved_bind_group execution_options = self._get_execution_options(execution_options) loader_options, _loader_options_have_wildcard = self._get_loader_options(load) id_attribute = get_instrumented_attr( @@ -1076,6 +1126,7 @@ def delete_where( load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, + bind_group: Optional[str] = None, **kwargs: Any, ) -> Sequence[ModelT]: """Delete instances specified by referenced kwargs and filters. @@ -1090,6 +1141,7 @@ def delete_where( load: Set default relationships to be loaded execution_options: Set default execution options uniquify: Optionally apply the ``unique()`` method to results before returning. + bind_group: Optional routing group for multi-master configurations. **kwargs: Arguments to apply to a delete Raises: @@ -1107,6 +1159,10 @@ def delete_where( with wrap_sqlalchemy_exception( error_messages=error_messages, dialect_name=self._dialect.name, wrap_exceptions=self.wrap_exceptions ): + resolved_bind_group = self._resolve_bind_group(bind_group) + if resolved_bind_group: + execution_options = dict(execution_options) if execution_options else {} + execution_options["bind_group"] = resolved_bind_group execution_options = self._get_execution_options(execution_options) loader_options, _loader_options_have_wildcard = self._get_loader_options(load) model_type = self.model_type @@ -1128,6 +1184,7 @@ def delete_where( execution_options=execution_options, auto_expunge=auto_expunge, use_cache=False, # Always fetch from DB for delete_where + bind_group=bind_group, **kwargs, ), ) @@ -1143,7 +1200,7 @@ def delete_where( for instance in instances: self._expunge(instance, auto_expunge=auto_expunge) # Queue cache invalidation (processed on commit) - self._queue_cache_invalidation(self.get_id_attribute_value(instance)) + self._queue_cache_invalidation(self.get_id_attribute_value(instance), resolved_bind_group) return instances def exists( @@ -1261,7 +1318,7 @@ def _get_delete_many_statement( return statement.where(id_attribute.in_(id_chunk)) # pyright: ignore[reportUnknownMemberType,reportUnknownVariableType] return statement.where(any_(id_chunk) == id_attribute) # type: ignore[arg-type] - def _get_uncached_impl( + def _get_from_db( self, item_id: Any, *, @@ -1272,11 +1329,16 @@ def _get_uncached_impl( load: Optional[LoadSpec], execution_options: Optional[dict[str, Any]], with_for_update: ForUpdateParameter, + bind_group: Optional[str] = None, ) -> ModelT: """Fetch an entity from the database without using cache.""" with wrap_sqlalchemy_exception( error_messages=error_messages, dialect_name=self._dialect.name, wrap_exceptions=self.wrap_exceptions ): + resolved_bind_group = self._resolve_bind_group(bind_group) + if resolved_bind_group: + execution_options = dict(execution_options) if execution_options else {} + execution_options["bind_group"] = resolved_bind_group resolved_execution_options = self._get_execution_options(execution_options) resolved_statement = self.statement if statement is None else statement loader_options, loader_options_have_wildcard = self._get_loader_options(load) @@ -1305,10 +1367,11 @@ def _get_cached_creator( load: Optional[LoadSpec], execution_options: Optional[dict[str, Any]], with_for_update: ForUpdateParameter, + bind_group: Optional[str] = None, ) -> ModelT: """Singleflight creator for get(id) caching (async).""" if self._cache_manager is None: - return self._get_uncached_impl( + return self._get_from_db( item_id, auto_expunge=auto_expunge, statement=statement, @@ -1317,13 +1380,14 @@ def _get_cached_creator( load=load, execution_options=execution_options, with_for_update=with_for_update, + bind_group=bind_group, ) - existing = self._cache_manager.get_entity_sync(model_name, item_id, self.model_type) + existing = self._cache_manager.get_entity_sync(model_name, item_id, self.model_type, bind_group=bind_group) if existing is not None: return existing - instance = self._get_uncached_impl( + instance = self._get_from_db( item_id, auto_expunge=auto_expunge, statement=statement, @@ -1332,11 +1396,12 @@ def _get_cached_creator( load=load, execution_options=execution_options, with_for_update=with_for_update, + bind_group=bind_group, ) - self._cache_manager.set_entity_sync(model_name, item_id, instance) + self._cache_manager.set_entity_sync(model_name, item_id, instance, bind_group=bind_group) return instance - def _list_uncached_impl( + def _list_from_db( self, *, filters: Sequence[Union[StatementFilter, ColumnElement[bool]]], @@ -1348,12 +1413,17 @@ def _list_uncached_impl( execution_options: Optional[dict[str, Any]], kwargs: dict[str, Any], uniquify: Optional[bool], + bind_group: Optional[str] = None, ) -> list[ModelT]: """Fetch a list of entities from the database without using cache.""" self._uniquify = self._get_uniquify(uniquify) with wrap_sqlalchemy_exception( error_messages=error_messages, dialect_name=self._dialect.name, wrap_exceptions=self.wrap_exceptions ): + resolved_bind_group = self._resolve_bind_group(bind_group) + if resolved_bind_group: + execution_options = dict(execution_options) if execution_options else {} + execution_options["bind_group"] = resolved_bind_group resolved_execution_options = self._get_execution_options(execution_options) resolved_statement = self.statement if statement is None else statement loader_options, loader_options_have_wildcard = self._get_loader_options(load) @@ -1386,10 +1456,11 @@ def _list_cached_creator( execution_options: Optional[dict[str, Any]], kwargs: dict[str, Any], uniquify: Optional[bool], + bind_group: Optional[str] = None, ) -> list[ModelT]: """Singleflight creator for list caching (async).""" if self._cache_manager is None: - return self._list_uncached_impl( + return self._list_from_db( filters=filters, auto_expunge=auto_expunge, statement=statement, @@ -1399,13 +1470,14 @@ def _list_cached_creator( execution_options=execution_options, kwargs=kwargs, uniquify=uniquify, + bind_group=bind_group, ) existing = self._cache_manager.get_list_sync(cache_key, self.model_type) if existing is not None: return existing - instances = self._list_uncached_impl( + instances = self._list_from_db( filters=filters, auto_expunge=auto_expunge, statement=statement, @@ -1415,11 +1487,12 @@ def _list_cached_creator( execution_options=execution_options, kwargs=kwargs, uniquify=uniquify, + bind_group=bind_group, ) self._cache_manager.set_list_sync(cache_key, list(instances)) return list(instances) - def _list_and_count_uncached_impl( + def _list_and_count_from_db( self, *, filters: Sequence[Union[StatementFilter, ColumnElement[bool]]], @@ -1432,9 +1505,14 @@ def _list_and_count_uncached_impl( execution_options: Optional[dict[str, Any]], kwargs: dict[str, Any], uniquify: Optional[bool], + bind_group: Optional[str] = None, ) -> tuple[list[ModelT], int]: """Fetch a list+count payload from the database without using cache.""" self._uniquify = self._get_uniquify(uniquify) + resolved_bind_group = self._resolve_bind_group(bind_group) + if resolved_bind_group: + execution_options = dict(execution_options) if execution_options else {} + execution_options["bind_group"] = resolved_bind_group if self._dialect.name in {"spanner", "spanner+spanner"} or not count_with_window_function: return self._list_and_count_basic( *filters, @@ -1471,10 +1549,11 @@ def _list_and_count_cached_creator( execution_options: Optional[dict[str, Any]], kwargs: dict[str, Any], uniquify: Optional[bool], + bind_group: Optional[str] = None, ) -> tuple[list[ModelT], int]: """Singleflight creator for list_and_count caching (async).""" if self._cache_manager is None: - return self._list_and_count_uncached_impl( + return self._list_and_count_from_db( filters=filters, auto_expunge=auto_expunge, statement=statement, @@ -1485,13 +1564,14 @@ def _list_and_count_cached_creator( execution_options=execution_options, kwargs=kwargs, uniquify=uniquify, + bind_group=bind_group, ) existing = self._cache_manager.get_list_and_count_sync(cache_key, self.model_type) if existing is not None: return existing - instances, count = self._list_and_count_uncached_impl( + instances, count = self._list_and_count_from_db( filters=filters, auto_expunge=auto_expunge, statement=statement, @@ -1502,6 +1582,7 @@ def _list_and_count_cached_creator( execution_options=execution_options, kwargs=kwargs, uniquify=uniquify, + bind_group=bind_group, ) self._cache_manager.set_list_and_count_sync(cache_key, list(instances), count) return list(instances), count @@ -1553,6 +1634,9 @@ def get( resolved_id_attribute = resolved_id_attribute.key cache_manager = self._cache_manager + # Resolve bind_group for cache key namespacing + resolved_bind_group = self._resolve_bind_group(bind_group) + if ( use_cache and cache_manager is not None @@ -1566,12 +1650,18 @@ def get( and execution_options is None ): model_name = cast("str", self.model_type.__tablename__) # type: ignore[attr-defined] - cached = cache_manager.get_entity_sync(model_name, item_id, self.model_type) + cached = cache_manager.get_entity_sync(model_name, item_id, self.model_type, bind_group=resolved_bind_group) if cached is not None: return cached + # Include bind_group in singleflight key to prevent cross-shard cache pollution + singleflight_key = ( + f"{model_name}:{resolved_bind_group}:get:{item_id}" + if resolved_bind_group + else f"{model_name}:get:{item_id}" + ) return cache_manager.singleflight_sync( - f"{model_name}:get:{item_id}", + singleflight_key, partial( self._get_cached_creator, model_name, @@ -1583,10 +1673,11 @@ def get( load=load, execution_options=execution_options, with_for_update=with_for_update, + bind_group=resolved_bind_group, ), ) - return self._get_uncached_impl( + return self._get_from_db( item_id, auto_expunge=auto_expunge, statement=statement, @@ -1595,6 +1686,7 @@ def get( load=load, execution_options=execution_options, with_for_update=with_for_update, + bind_group=bind_group, ) def get_one( @@ -1731,6 +1823,7 @@ def get_or_upsert( load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, + bind_group: Optional[str] = None, **kwargs: Any, ) -> tuple[ModelT, bool]: """Get instance identified by ``kwargs`` or create if it doesn't exist. @@ -1754,6 +1847,7 @@ def get_or_upsert( load: Set relationships to be loaded execution_options: Set default execution options uniquify: Optionally apply the ``unique()`` method to results before returning. + bind_group: Optional routing group for multi-master configurations. **kwargs: Identifier of the instance to be retrieved. Returns: @@ -1782,6 +1876,7 @@ def get_or_upsert( **match_filter, load=load, execution_options=execution_options, + bind_group=bind_group, ) if not existing: return ( @@ -1790,6 +1885,7 @@ def get_or_upsert( auto_commit=auto_commit, auto_expunge=auto_expunge, auto_refresh=auto_refresh, + bind_group=bind_group, ), True, ) @@ -1822,6 +1918,7 @@ def get_and_update( load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, + bind_group: Optional[str] = None, **kwargs: Any, ) -> tuple[ModelT, bool]: """Get instance identified by ``kwargs`` and update the model if the arguments are different. @@ -1843,6 +1940,7 @@ def get_and_update( load: Set relationships to be loaded execution_options: Set default execution options uniquify: Optionally apply the ``unique()`` method to results before returning. + bind_group: Optional routing group for multi-master configurations. **kwargs: Identifier of the instance to be retrieved. Returns: @@ -1866,7 +1964,9 @@ def get_and_update( } else: match_filter = kwargs - existing = self.get_one(*filters, **match_filter, load=load, execution_options=execution_options) + existing = self.get_one( + *filters, **match_filter, load=load, execution_options=execution_options, bind_group=bind_group + ) updated = False for field_name, new_field_value in kwargs.items(): field = getattr(existing, field_name, MISSING) @@ -1954,6 +2054,7 @@ def update( load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, + bind_group: Optional[str] = None, ) -> ModelT: """Update instance with the attribute values present on `data`. @@ -1975,6 +2076,7 @@ def update( load: Set relationships to be loaded execution_options: Set default execution options uniquify: Optionally apply the ``unique()`` method to results before returning. + bind_group: Optional routing group for multi-master configurations. Returns: The updated instance. @@ -1997,6 +2099,7 @@ def update( load=load, execution_options=execution_options, with_for_update=with_for_update, + bind_group=bind_group, ) mapper = None with ( @@ -2070,6 +2173,7 @@ def update_many( load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, + bind_group: Optional[str] = None, ) -> list[ModelT]: """Update one or more instances with the attribute values present on `data`. @@ -2087,6 +2191,7 @@ def update_many( load: Set default relationships to be loaded execution_options: Set default execution options uniquify: Optionally apply the ``unique()`` method to results before returning. + bind_group: Optional routing group for multi-master configurations. Returns: The updated instances. @@ -2110,6 +2215,10 @@ def update_many( with wrap_sqlalchemy_exception( error_messages=error_messages, dialect_name=self._dialect.name, wrap_exceptions=self.wrap_exceptions ): + resolved_bind_group = self._resolve_bind_group(bind_group) + if resolved_bind_group: + execution_options = dict(execution_options) if execution_options else {} + execution_options["bind_group"] = resolved_bind_group execution_options = self._get_execution_options(execution_options) loader_options = self._get_loader_options(load)[0] supports_returning = self._dialect.update_executemany_returning and self._dialect.name != "oracle" @@ -2142,6 +2251,7 @@ def update_many( getattr(self.model_type, self.id_attribute).in_(updated_ids), load=loader_options, execution_options=execution_options, + bind_group=bind_group, ) for instance in updated_instances: self._expunge(instance, auto_expunge=auto_expunge) @@ -2220,7 +2330,7 @@ def list_and_count( and load is None and not self._default_loader_options ): - return self._list_and_count_uncached_impl( + return self._list_and_count_from_db( filters=filters, auto_expunge=auto_expunge, statement=statement, @@ -2231,6 +2341,7 @@ def list_and_count( execution_options=execution_options, kwargs=kwargs, uniquify=uniquify, + bind_group=bind_group, ) model_name = cast("str", self.model_type.__tablename__) # type: ignore[attr-defined] @@ -2247,7 +2358,7 @@ def list_and_count( count_with_window_function=count_with_window_function, ) if cache_key is None: - return self._list_and_count_uncached_impl( + return self._list_and_count_from_db( filters=filters, auto_expunge=auto_expunge, statement=statement, @@ -2258,6 +2369,7 @@ def list_and_count( execution_options=execution_options, kwargs=kwargs, uniquify=uniquify, + bind_group=bind_group, ) cached = cache_manager.get_list_and_count_sync(cache_key, self.model_type) @@ -2279,6 +2391,7 @@ def list_and_count( execution_options=execution_options, kwargs=kwargs, uniquify=uniquify, + bind_group=bind_group, ), ) @@ -2498,6 +2611,7 @@ def upsert( load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, + bind_group: Optional[str] = None, ) -> ModelT: """Modify or create instance. @@ -2522,6 +2636,7 @@ def upsert( load: Set relationships to be loaded execution_options: Set default execution options uniquify: Optionally apply the ``unique()`` method to results before returning. + bind_group: Optional routing group for multi-master configurations. Returns: The updated or created instance. @@ -2541,13 +2656,16 @@ def upsert( match_filter = {self.id_attribute: getattr(data, self.id_attribute, None)} else: match_filter = data.to_dict(exclude={self.id_attribute}) - existing = self.get_one_or_none(load=load, execution_options=execution_options, **match_filter) + existing = self.get_one_or_none( + load=load, execution_options=execution_options, bind_group=bind_group, **match_filter + ) if not existing: return self.add( data, auto_commit=auto_commit, auto_expunge=auto_expunge, auto_refresh=auto_refresh, + bind_group=bind_group, ) with wrap_sqlalchemy_exception( error_messages=error_messages, dialect_name=self._dialect.name, wrap_exceptions=self.wrap_exceptions @@ -2566,7 +2684,7 @@ def upsert( ) self._expunge(instance, auto_expunge=auto_expunge) # Queue cache invalidation (processed on commit) - self._queue_cache_invalidation(self.get_id_attribute_value(instance)) + self._queue_cache_invalidation(self.get_id_attribute_value(instance), bind_group) return instance def upsert_many( @@ -2581,6 +2699,7 @@ def upsert_many( load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, + bind_group: Optional[str] = None, ) -> list[ModelT]: """Modify or create multiple instances. @@ -2604,6 +2723,7 @@ def upsert_many( load: Set default relationships to be loaded execution_options: Set default execution options uniquify: Optionally apply the ``unique()`` method to results before returning. + bind_group: Optional routing group to use for the operation. Returns: The updated or created instance. @@ -2638,6 +2758,7 @@ def upsert_many( load=load, execution_options=execution_options, auto_expunge=False, + bind_group=bind_group, ) for field_name in match_fields: field = get_instrumented_attr(self.model_type, field_name) @@ -2656,7 +2777,7 @@ def upsert_many( data_to_insert.append(datum) if data_to_insert: instances.extend( - self.add_many(data_to_insert, auto_commit=False, auto_expunge=False), + self.add_many(data_to_insert, auto_commit=False, auto_expunge=False, bind_group=bind_group), ) if data_to_update: instances.extend( @@ -2666,6 +2787,7 @@ def upsert_many( auto_expunge=False, load=load, execution_options=execution_options, + bind_group=bind_group, ), ) self._flush_or_commit(auto_commit=auto_commit) @@ -2757,7 +2879,7 @@ def list( and load is None and not self._default_loader_options ): - return self._list_uncached_impl( + return self._list_from_db( filters=filters, auto_expunge=auto_expunge, statement=statement, @@ -2767,6 +2889,7 @@ def list( execution_options=execution_options, kwargs=kwargs, uniquify=uniquify, + bind_group=bind_group, ) model_name = cast("str", self.model_type.__tablename__) # type: ignore[attr-defined] @@ -2782,7 +2905,7 @@ def list( uniquify=self._uniquify, ) if cache_key is None: - return self._list_uncached_impl( + return self._list_from_db( filters=filters, auto_expunge=auto_expunge, statement=statement, @@ -2792,6 +2915,7 @@ def list( execution_options=execution_options, kwargs=kwargs, uniquify=uniquify, + bind_group=bind_group, ) cached = cache_manager.get_list_sync(cache_key, self.model_type) @@ -2812,6 +2936,7 @@ def list( execution_options=execution_options, kwargs=kwargs, uniquify=uniquify, + bind_group=bind_group, ), ) diff --git a/advanced_alchemy/repository/memory/_async.py b/advanced_alchemy/repository/memory/_async.py index 37df5060a..4aa925e89 100644 --- a/advanced_alchemy/repository/memory/_async.py +++ b/advanced_alchemy/repository/memory/_async.py @@ -507,6 +507,7 @@ async def get_or_upsert( load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, + bind_group: Optional[str] = None, **kwargs: Any, ) -> tuple[ModelT, bool]: kwargs_ = self._exclude_unused_kwargs(kwargs) @@ -543,6 +544,7 @@ async def get_and_update( load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, + bind_group: Optional[str] = None, **kwargs: Any, ) -> tuple[ModelT, bool]: kwargs_ = self._exclude_unused_kwargs(kwargs) @@ -593,6 +595,7 @@ async def add( auto_expunge: Optional[bool] = None, auto_refresh: Optional[bool] = None, error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, + bind_group: Optional[str] = None, ) -> ModelT: try: self.__database__.add(self.model_type, data) @@ -608,6 +611,7 @@ async def add_many( auto_commit: Optional[bool] = None, auto_expunge: Optional[bool] = None, error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, + bind_group: Optional[str] = None, ) -> list[ModelT]: for obj in data: await self.add(obj) # pyright: ignore[reportCallIssue] @@ -627,6 +631,7 @@ async def update( load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, + bind_group: Optional[str] = None, ) -> ModelT: self._find_or_raise_not_found(self.__collection__().key(data)) return self.__collection__().update(data) @@ -641,6 +646,7 @@ async def update_many( load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, + bind_group: Optional[str] = None, ) -> list[ModelT]: return [self.__collection__().update(obj) for obj in data if obj in self.__collection__()] @@ -655,6 +661,7 @@ async def delete( load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, + bind_group: Optional[str] = None, ) -> ModelT: try: return self._find_or_raise_not_found(item_id) @@ -673,6 +680,7 @@ async def delete_many( load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, + bind_group: Optional[str] = None, ) -> list[ModelT]: deleted: list[ModelT] = [] for id_ in item_ids: @@ -691,6 +699,7 @@ async def delete_where( load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, + bind_group: Optional[str] = None, **kwargs: Any, ) -> list[ModelT]: result = self.__collection__().list() @@ -713,6 +722,7 @@ async def upsert( load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, + bind_group: Optional[str] = None, ) -> ModelT: # sourcery skip: assign-if-exp, reintroduce-else if data in self.__collection__(): @@ -731,6 +741,7 @@ async def upsert_many( load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, + bind_group: Optional[str] = None, ) -> list[ModelT]: return [await self.upsert(item) for item in data] diff --git a/advanced_alchemy/repository/memory/_sync.py b/advanced_alchemy/repository/memory/_sync.py index 579d73fd9..3966a70f3 100644 --- a/advanced_alchemy/repository/memory/_sync.py +++ b/advanced_alchemy/repository/memory/_sync.py @@ -508,6 +508,7 @@ def get_or_upsert( load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, + bind_group: Optional[str] = None, **kwargs: Any, ) -> tuple[ModelT, bool]: kwargs_ = self._exclude_unused_kwargs(kwargs) @@ -544,6 +545,7 @@ def get_and_update( load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, + bind_group: Optional[str] = None, **kwargs: Any, ) -> tuple[ModelT, bool]: kwargs_ = self._exclude_unused_kwargs(kwargs) @@ -594,6 +596,7 @@ def add( auto_expunge: Optional[bool] = None, auto_refresh: Optional[bool] = None, error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, + bind_group: Optional[str] = None, ) -> ModelT: try: self.__database__.add(self.model_type, data) @@ -609,6 +612,7 @@ def add_many( auto_commit: Optional[bool] = None, auto_expunge: Optional[bool] = None, error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, + bind_group: Optional[str] = None, ) -> list[ModelT]: for obj in data: self.add(obj) # pyright: ignore[reportCallIssue] @@ -628,6 +632,7 @@ def update( load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, + bind_group: Optional[str] = None, ) -> ModelT: self._find_or_raise_not_found(self.__collection__().key(data)) return self.__collection__().update(data) @@ -642,6 +647,7 @@ def update_many( load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, + bind_group: Optional[str] = None, ) -> list[ModelT]: return [self.__collection__().update(obj) for obj in data if obj in self.__collection__()] @@ -656,6 +662,7 @@ def delete( load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, + bind_group: Optional[str] = None, ) -> ModelT: try: return self._find_or_raise_not_found(item_id) @@ -674,6 +681,7 @@ def delete_many( load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, + bind_group: Optional[str] = None, ) -> list[ModelT]: deleted: list[ModelT] = [] for id_ in item_ids: @@ -692,6 +700,7 @@ def delete_where( load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, + bind_group: Optional[str] = None, **kwargs: Any, ) -> list[ModelT]: result = self.__collection__().list() @@ -714,6 +723,7 @@ def upsert( load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, + bind_group: Optional[str] = None, ) -> ModelT: # sourcery skip: assign-if-exp, reintroduce-else if data in self.__collection__(): @@ -732,6 +742,7 @@ def upsert_many( load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, + bind_group: Optional[str] = None, ) -> list[ModelT]: return [self.upsert(item) for item in data] diff --git a/advanced_alchemy/service/_async.py b/advanced_alchemy/service/_async.py index dd8c814ac..cbe191033 100644 --- a/advanced_alchemy/service/_async.py +++ b/advanced_alchemy/service/_async.py @@ -551,6 +551,7 @@ async def list_and_count( execution_options=execution_options, uniquify=self._get_uniquify(uniquify), use_cache=use_cache, + bind_group=bind_group, **kwargs, ), ) @@ -648,6 +649,7 @@ async def list( execution_options=execution_options, uniquify=self._get_uniquify(uniquify), use_cache=use_cache, + bind_group=bind_group, **kwargs, ), ) @@ -667,6 +669,7 @@ async def create( auto_expunge: Optional[bool] = None, auto_refresh: Optional[bool] = None, error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, + bind_group: Optional[str] = None, ) -> "ModelT": """Wrap repository instance creation. @@ -677,6 +680,7 @@ async def create( auto_commit: Commit objects before returning. error_messages: An optional dictionary of templates to use for friendlier error messages to clients + bind_group: Optional routing group to use for the operation. Returns: Representation of created instance. @@ -690,6 +694,7 @@ async def create( auto_expunge=auto_expunge, auto_refresh=auto_refresh, error_messages=error_messages, + bind_group=bind_group, ), ) @@ -700,6 +705,7 @@ async def create_many( auto_commit: Optional[bool] = None, auto_expunge: Optional[bool] = None, error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, + bind_group: Optional[str] = None, ) -> Sequence[ModelT]: """Wrap repository bulk instance creation. @@ -709,6 +715,7 @@ async def create_many( auto_commit: Commit objects before returning. error_messages: An optional dictionary of templates to use for friendlier error messages to clients + bind_group: Optional routing group to use for the operation. Returns: Representation of created instances. @@ -723,6 +730,7 @@ async def create_many( auto_commit=auto_commit, auto_expunge=auto_expunge, error_messages=error_messages, + bind_group=bind_group, ), ) @@ -741,6 +749,7 @@ async def update( load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, + bind_group: Optional[str] = None, ) -> "ModelT": """Wrap repository update operation. @@ -762,6 +771,7 @@ async def update( load: Set default relationships to be loaded execution_options: Set default execution options uniquify: Optionally apply the ``unique()`` method to results before returning. + bind_group: Optional routing group to use for the operation. Raises: RepositoryError: If no configuration or session is provided. @@ -783,6 +793,7 @@ async def update( load=load, execution_options=execution_options, with_for_update=with_for_update, + bind_group=bind_group, ) # Extract attributes from converted model to update existing instance @@ -823,6 +834,7 @@ async def update( load=load, execution_options=execution_options, uniquify=self._get_uniquify(uniquify), + bind_group=bind_group, ), ) @@ -836,6 +848,7 @@ async def update_many( load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, + bind_group: Optional[str] = None, ) -> Sequence[ModelT]: """Wrap repository bulk instance update. @@ -848,6 +861,7 @@ async def update_many( load: Set default relationships to be loaded execution_options: Set default execution options uniquify: Optionally apply the ``unique()`` method to results before returning. + bind_group: Optional routing group to use for the operation. Returns: Representation of updated instances. @@ -865,6 +879,7 @@ async def update_many( load=load, execution_options=execution_options, uniquify=self._get_uniquify(uniquify), + bind_group=bind_group, ), ) @@ -883,6 +898,7 @@ async def upsert( load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, + bind_group: Optional[str] = None, ) -> ModelT: """Wrap repository upsert operation. @@ -905,6 +921,7 @@ async def upsert( load: Set default relationships to be loaded execution_options: Set default execution options uniquify: Optionally apply the ``unique()`` method to results before returning. + bind_group: Optional routing group to use for the operation. Returns: Updated or created representation. @@ -927,6 +944,7 @@ async def upsert( load=load, execution_options=execution_options, uniquify=self._get_uniquify(uniquify), + bind_group=bind_group, ), ) @@ -942,6 +960,7 @@ async def upsert_many( load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, + bind_group: Optional[str] = None, ) -> Sequence[ModelT]: """Wrap repository upsert operation. @@ -957,6 +976,7 @@ async def upsert_many( load: Set default relationships to be loaded execution_options: Set default execution options uniquify: Optionally apply the ``unique()`` method to results before returning. + bind_group: Optional routing group to use for the operation. Returns: Updated or created representation. @@ -976,6 +996,7 @@ async def upsert_many( load=load, execution_options=execution_options, uniquify=self._get_uniquify(uniquify), + bind_group=bind_group, ), ) @@ -993,6 +1014,7 @@ async def get_or_upsert( load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, + bind_group: Optional[str] = None, **kwargs: Any, ) -> tuple[ModelT, bool]: """Wrap repository instance creation. @@ -1017,6 +1039,7 @@ async def get_or_upsert( load: Set default relationships to be loaded execution_options: Set default execution options uniquify: Optionally apply the ``unique()`` method to results before returning. + bind_group: Optional routing group to use for the operation. **kwargs: Identifier of the instance to be retrieved. Returns: @@ -1039,6 +1062,7 @@ async def get_or_upsert( load=load, execution_options=execution_options, uniquify=self._get_uniquify(uniquify), + bind_group=bind_group, **validated_model.to_dict(), ), ) @@ -1056,6 +1080,7 @@ async def get_and_update( load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, + bind_group: Optional[str] = None, **kwargs: Any, ) -> tuple[ModelT, bool]: """Wrap repository instance creation. @@ -1077,6 +1102,7 @@ async def get_and_update( load: Set default relationships to be loaded execution_options: Set default execution options uniquify: Optionally apply the ``unique()`` method to results before returning. + bind_group: Optional routing group to use for the operation. **kwargs: Identifier of the instance to be retrieved. Returns: @@ -1098,6 +1124,7 @@ async def get_and_update( load=load, execution_options=execution_options, uniquify=self._get_uniquify(uniquify), + bind_group=bind_group, **validated_model.to_dict(), ), ) @@ -1113,6 +1140,7 @@ async def delete( load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, + bind_group: Optional[str] = None, ) -> ModelT: """Wrap repository delete operation. @@ -1127,6 +1155,7 @@ async def delete( load: Set default relationships to be loaded execution_options: Set default execution options uniquify: Optionally apply the ``unique()`` method to results before returning. + bind_group: Optional routing group to use for the operation. Returns: Representation of the deleted instance. @@ -1142,6 +1171,7 @@ async def delete( load=load, execution_options=execution_options, uniquify=self._get_uniquify(uniquify), + bind_group=bind_group, ), ) @@ -1157,6 +1187,7 @@ async def delete_many( load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, + bind_group: Optional[str] = None, ) -> Sequence[ModelT]: """Wrap repository bulk instance deletion. @@ -1173,6 +1204,7 @@ async def delete_many( load: Set default relationships to be loaded execution_options: Set default execution options uniquify: Optionally apply the ``unique()`` method to results before returning. + bind_group: Optional routing group to use for the operation. Returns: Representation of removed instances. @@ -1189,6 +1221,7 @@ async def delete_many( load=load, execution_options=execution_options, uniquify=self._get_uniquify(uniquify), + bind_group=bind_group, ), ) @@ -1202,6 +1235,7 @@ async def delete_where( load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, + bind_group: Optional[str] = None, **kwargs: Any, ) -> Sequence[ModelT]: """Wrap repository scalars operation. @@ -1216,6 +1250,7 @@ async def delete_where( load: Set default relationships to be loaded execution_options: Set default execution options uniquify: Optionally apply the ``unique()`` method to results before returning. + bind_group: Optional routing group to use for the operation. **kwargs: Instance attribute value filters. Returns: @@ -1232,6 +1267,7 @@ async def delete_where( load=load, execution_options=execution_options, uniquify=self._get_uniquify(uniquify), + bind_group=bind_group, **kwargs, ), ) diff --git a/advanced_alchemy/service/_sync.py b/advanced_alchemy/service/_sync.py index c027b2e04..9ca1384f9 100644 --- a/advanced_alchemy/service/_sync.py +++ b/advanced_alchemy/service/_sync.py @@ -550,6 +550,7 @@ def list_and_count( execution_options=execution_options, uniquify=self._get_uniquify(uniquify), use_cache=use_cache, + bind_group=bind_group, **kwargs, ), ) @@ -647,6 +648,7 @@ def list( execution_options=execution_options, uniquify=self._get_uniquify(uniquify), use_cache=use_cache, + bind_group=bind_group, **kwargs, ), ) @@ -666,6 +668,7 @@ def create( auto_expunge: Optional[bool] = None, auto_refresh: Optional[bool] = None, error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, + bind_group: Optional[str] = None, ) -> "ModelT": """Wrap repository instance creation. @@ -676,6 +679,7 @@ def create( auto_commit: Commit objects before returning. error_messages: An optional dictionary of templates to use for friendlier error messages to clients + bind_group: Optional routing group to use for the operation. Returns: Representation of created instance. @@ -689,6 +693,7 @@ def create( auto_expunge=auto_expunge, auto_refresh=auto_refresh, error_messages=error_messages, + bind_group=bind_group, ), ) @@ -699,6 +704,7 @@ def create_many( auto_commit: Optional[bool] = None, auto_expunge: Optional[bool] = None, error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty, + bind_group: Optional[str] = None, ) -> Sequence[ModelT]: """Wrap repository bulk instance creation. @@ -708,6 +714,7 @@ def create_many( auto_commit: Commit objects before returning. error_messages: An optional dictionary of templates to use for friendlier error messages to clients + bind_group: Optional routing group to use for the operation. Returns: Representation of created instances. @@ -722,6 +729,7 @@ def create_many( auto_commit=auto_commit, auto_expunge=auto_expunge, error_messages=error_messages, + bind_group=bind_group, ), ) @@ -740,6 +748,7 @@ def update( load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, + bind_group: Optional[str] = None, ) -> "ModelT": """Wrap repository update operation. @@ -761,6 +770,7 @@ def update( load: Set default relationships to be loaded execution_options: Set default execution options uniquify: Optionally apply the ``unique()`` method to results before returning. + bind_group: Optional routing group to use for the operation. Raises: RepositoryError: If no configuration or session is provided. @@ -782,6 +792,7 @@ def update( load=load, execution_options=execution_options, with_for_update=with_for_update, + bind_group=bind_group, ) # Extract attributes from converted model to update existing instance @@ -822,6 +833,7 @@ def update( load=load, execution_options=execution_options, uniquify=self._get_uniquify(uniquify), + bind_group=bind_group, ), ) @@ -835,6 +847,7 @@ def update_many( load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, + bind_group: Optional[str] = None, ) -> Sequence[ModelT]: """Wrap repository bulk instance update. @@ -847,6 +860,7 @@ def update_many( load: Set default relationships to be loaded execution_options: Set default execution options uniquify: Optionally apply the ``unique()`` method to results before returning. + bind_group: Optional routing group to use for the operation. Returns: Representation of updated instances. @@ -864,6 +878,7 @@ def update_many( load=load, execution_options=execution_options, uniquify=self._get_uniquify(uniquify), + bind_group=bind_group, ), ) @@ -882,6 +897,7 @@ def upsert( load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, + bind_group: Optional[str] = None, ) -> ModelT: """Wrap repository upsert operation. @@ -904,6 +920,7 @@ def upsert( load: Set default relationships to be loaded execution_options: Set default execution options uniquify: Optionally apply the ``unique()`` method to results before returning. + bind_group: Optional routing group to use for the operation. Returns: Updated or created representation. @@ -926,6 +943,7 @@ def upsert( load=load, execution_options=execution_options, uniquify=self._get_uniquify(uniquify), + bind_group=bind_group, ), ) @@ -941,6 +959,7 @@ def upsert_many( load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, + bind_group: Optional[str] = None, ) -> Sequence[ModelT]: """Wrap repository upsert operation. @@ -956,6 +975,7 @@ def upsert_many( load: Set default relationships to be loaded execution_options: Set default execution options uniquify: Optionally apply the ``unique()`` method to results before returning. + bind_group: Optional routing group to use for the operation. Returns: Updated or created representation. @@ -975,6 +995,7 @@ def upsert_many( load=load, execution_options=execution_options, uniquify=self._get_uniquify(uniquify), + bind_group=bind_group, ), ) @@ -992,6 +1013,7 @@ def get_or_upsert( load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, + bind_group: Optional[str] = None, **kwargs: Any, ) -> tuple[ModelT, bool]: """Wrap repository instance creation. @@ -1016,6 +1038,7 @@ def get_or_upsert( load: Set default relationships to be loaded execution_options: Set default execution options uniquify: Optionally apply the ``unique()`` method to results before returning. + bind_group: Optional routing group to use for the operation. **kwargs: Identifier of the instance to be retrieved. Returns: @@ -1038,6 +1061,7 @@ def get_or_upsert( load=load, execution_options=execution_options, uniquify=self._get_uniquify(uniquify), + bind_group=bind_group, **validated_model.to_dict(), ), ) @@ -1055,6 +1079,7 @@ def get_and_update( load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, + bind_group: Optional[str] = None, **kwargs: Any, ) -> tuple[ModelT, bool]: """Wrap repository instance creation. @@ -1076,6 +1101,7 @@ def get_and_update( load: Set default relationships to be loaded execution_options: Set default execution options uniquify: Optionally apply the ``unique()`` method to results before returning. + bind_group: Optional routing group to use for the operation. **kwargs: Identifier of the instance to be retrieved. Returns: @@ -1097,6 +1123,7 @@ def get_and_update( load=load, execution_options=execution_options, uniquify=self._get_uniquify(uniquify), + bind_group=bind_group, **validated_model.to_dict(), ), ) @@ -1112,6 +1139,7 @@ def delete( load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, + bind_group: Optional[str] = None, ) -> ModelT: """Wrap repository delete operation. @@ -1126,6 +1154,7 @@ def delete( load: Set default relationships to be loaded execution_options: Set default execution options uniquify: Optionally apply the ``unique()`` method to results before returning. + bind_group: Optional routing group to use for the operation. Returns: Representation of the deleted instance. @@ -1141,6 +1170,7 @@ def delete( load=load, execution_options=execution_options, uniquify=self._get_uniquify(uniquify), + bind_group=bind_group, ), ) @@ -1156,6 +1186,7 @@ def delete_many( load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, + bind_group: Optional[str] = None, ) -> Sequence[ModelT]: """Wrap repository bulk instance deletion. @@ -1172,6 +1203,7 @@ def delete_many( load: Set default relationships to be loaded execution_options: Set default execution options uniquify: Optionally apply the ``unique()`` method to results before returning. + bind_group: Optional routing group to use for the operation. Returns: Representation of removed instances. @@ -1188,6 +1220,7 @@ def delete_many( load=load, execution_options=execution_options, uniquify=self._get_uniquify(uniquify), + bind_group=bind_group, ), ) @@ -1201,6 +1234,7 @@ def delete_where( load: Optional[LoadSpec] = None, execution_options: Optional[dict[str, Any]] = None, uniquify: Optional[bool] = None, + bind_group: Optional[str] = None, **kwargs: Any, ) -> Sequence[ModelT]: """Wrap repository scalars operation. @@ -1215,6 +1249,7 @@ def delete_where( load: Set default relationships to be loaded execution_options: Set default execution options uniquify: Optionally apply the ``unique()`` method to results before returning. + bind_group: Optional routing group to use for the operation. **kwargs: Instance attribute value filters. Returns: @@ -1231,6 +1266,7 @@ def delete_where( load=load, execution_options=execution_options, uniquify=self._get_uniquify(uniquify), + bind_group=bind_group, **kwargs, ), ) From 9676a186e837af9bc0c5f81ee6b814b500bc4b96 Mon Sep 17 00:00:00 2001 From: Cody Fincher Date: Sun, 18 Jan 2026 04:59:51 +0000 Subject: [PATCH 06/10] feat: Add bind_group support to repository cache invalidation in CRUD operations and introduce integration tests. --- advanced_alchemy/repository/_async.py | 8 + advanced_alchemy/repository/_sync.py | 8 + tests/integration/test_cache_bind_group.py | 784 +++++++++++++++++++++ 3 files changed, 800 insertions(+) create mode 100644 tests/integration/test_cache_bind_group.py diff --git a/advanced_alchemy/repository/_async.py b/advanced_alchemy/repository/_async.py index 3f353cf9a..777961e97 100644 --- a/advanced_alchemy/repository/_async.py +++ b/advanced_alchemy/repository/_async.py @@ -1006,6 +1006,8 @@ async def delete( await self.session.delete(instance) await self._flush_or_commit(auto_commit=auto_commit) self._expunge(instance, auto_expunge=auto_expunge) + # Queue cache invalidation (processed on commit) + self._queue_cache_invalidation(item_id, bind_group) return instance async def delete_many( @@ -1109,6 +1111,8 @@ async def delete_many( await self._flush_or_commit(auto_commit=auto_commit) for instance in instances: self._expunge(instance, auto_expunge=auto_expunge) + # Queue cache invalidation (processed on commit) + self._queue_cache_invalidation(self.get_id_attribute_value(instance), bind_group) return instances @staticmethod @@ -2166,6 +2170,8 @@ async def update( auto_refresh=auto_refresh, ) self._expunge(instance, auto_expunge=auto_expunge) + # Queue cache invalidation (processed on commit) + self._queue_cache_invalidation(self.get_id_attribute_value(instance), bind_group) return instance async def update_many( @@ -2260,6 +2266,8 @@ async def update_many( ) for instance in updated_instances: self._expunge(instance, auto_expunge=auto_expunge) + # Queue cache invalidation (processed on commit) + self._queue_cache_invalidation(self.get_id_attribute_value(instance), bind_group) return updated_instances def _get_update_many_statement( diff --git a/advanced_alchemy/repository/_sync.py b/advanced_alchemy/repository/_sync.py index fa4101846..42efa88f9 100644 --- a/advanced_alchemy/repository/_sync.py +++ b/advanced_alchemy/repository/_sync.py @@ -1007,6 +1007,8 @@ def delete( self.session.delete(instance) self._flush_or_commit(auto_commit=auto_commit) self._expunge(instance, auto_expunge=auto_expunge) + # Queue cache invalidation (processed on commit) + self._queue_cache_invalidation(item_id, bind_group) return instance def delete_many( @@ -1110,6 +1112,8 @@ def delete_many( self._flush_or_commit(auto_commit=auto_commit) for instance in instances: self._expunge(instance, auto_expunge=auto_expunge) + # Queue cache invalidation (processed on commit) + self._queue_cache_invalidation(self.get_id_attribute_value(instance), bind_group) return instances @staticmethod @@ -2161,6 +2165,8 @@ def update( auto_refresh=auto_refresh, ) self._expunge(instance, auto_expunge=auto_expunge) + # Queue cache invalidation (processed on commit) + self._queue_cache_invalidation(self.get_id_attribute_value(instance), bind_group) return instance def update_many( @@ -2255,6 +2261,8 @@ def update_many( ) for instance in updated_instances: self._expunge(instance, auto_expunge=auto_expunge) + # Queue cache invalidation (processed on commit) + self._queue_cache_invalidation(self.get_id_attribute_value(instance), bind_group) return updated_instances def _get_update_many_statement( diff --git a/tests/integration/test_cache_bind_group.py b/tests/integration/test_cache_bind_group.py new file mode 100644 index 000000000..39fa26847 --- /dev/null +++ b/tests/integration/test_cache_bind_group.py @@ -0,0 +1,784 @@ +"""Integration tests for repository caching with bind_group support. + +These tests verify that cache keys properly include bind_group for multi-master +database configurations, preventing data leaks between database shards. +""" + +import asyncio +from collections.abc import Generator +from typing import TYPE_CHECKING, Any, Optional, cast + +import pytest +from sqlalchemy import Engine, String, event +from sqlalchemy.ext.asyncio import AsyncEngine +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column + +from advanced_alchemy.base import UUIDBase +from advanced_alchemy.cache import setup_cache_listeners +from advanced_alchemy.cache.config import CacheConfig +from advanced_alchemy.cache.manager import DOGPILE_CACHE_INSTALLED, CacheManager +from advanced_alchemy.repository import SQLAlchemyAsyncRepository, SQLAlchemySyncRepository + +if TYPE_CHECKING: + pass + +pytestmark = [ + pytest.mark.integration, + pytest.mark.xdist_group("cache"), +] + + +@pytest.fixture(scope="module", autouse=True) +def _setup_cache_listeners() -> Generator[None, None, None]: + """Set up global cache listeners for all tests in this module.""" + setup_cache_listeners() + yield + + +# Module-level cache for model and counter for unique names +_model_cache: dict[str, type] = {} +_class_counter = 0 + + +def get_bind_group_author_model(engine_dialect_name: str, worker_id: str) -> type[DeclarativeBase]: + """Create appropriate model for bind_group tests.""" + global _class_counter + cache_key = f"bind_group_author_{worker_id}_{engine_dialect_name}" + + if cache_key not in _model_cache: + + class TestBase(DeclarativeBase): + pass + + _class_counter += 1 + unique_suffix = f"{_class_counter}_{worker_id}_{engine_dialect_name}" + + class_name = f"BindGroupAuthor_{unique_suffix}" + + BindGroupAuthor = type( + class_name, + (UUIDBase, TestBase), + { + "__tablename__": f"test_bind_group_authors_{worker_id}_{engine_dialect_name}", + "__mapper_args__": {"concrete": True}, + "__module__": __name__, + "name": mapped_column(String(length=100)), + "bio": mapped_column(String(length=500), nullable=True), + "__annotations__": {"name": Mapped[str], "bio": Mapped[Optional[str]]}, + }, + ) + + _model_cache[cache_key] = BindGroupAuthor + + return _model_cache[cache_key] + + +def get_worker_id(request: pytest.FixtureRequest) -> str: + """Get worker ID for pytest-xdist or 'master' for single process.""" + workerinput = getattr(request.config, "workerinput", None) + if isinstance(workerinput, dict): + return cast("str", workerinput.get("workerid", "master")) + return "master" + + +@pytest.fixture +def memory_cache_manager() -> CacheManager: + """Create a CacheManager with memory backend for testing.""" + config = CacheConfig( + backend="dogpile.cache.memory", + expiration_time=300, + key_prefix="test_bind_group:", + ) + return CacheManager(config) + + +# ============================================================================ +# Cache Invalidation Tracker Tests +# ============================================================================ + + +@pytest.mark.skipif(not DOGPILE_CACHE_INSTALLED, reason="dogpile.cache not installed") +def test_cache_invalidation_tracker_add_invalidation_with_bind_group(memory_cache_manager: CacheManager) -> None: + """Test CacheInvalidationTracker stores bind_group with invalidations.""" + from advanced_alchemy._listeners import CacheInvalidationTracker + + tracker = CacheInvalidationTracker(memory_cache_manager) + + # Add invalidations with different bind_groups + tracker.add_invalidation("model_a", "id1", bind_group=None) + tracker.add_invalidation("model_a", "id2", bind_group="shard_a") + tracker.add_invalidation("model_b", "id3", bind_group="shard_b") + + # Verify pending invalidations stored correctly + assert len(tracker._pending_invalidations) == 3 + assert ("model_a", "id1", None) in tracker._pending_invalidations + assert ("model_a", "id2", "shard_a") in tracker._pending_invalidations + assert ("model_b", "id3", "shard_b") in tracker._pending_invalidations + + # Verify model bumps queued + assert "model_a" in tracker._pending_model_bumps + assert "model_b" in tracker._pending_model_bumps + + +@pytest.mark.skipif(not DOGPILE_CACHE_INSTALLED, reason="dogpile.cache not installed") +def test_cache_invalidation_tracker_rollback_clears_pending(memory_cache_manager: CacheManager) -> None: + """Test CacheInvalidationTracker rollback clears all pending invalidations.""" + from advanced_alchemy._listeners import CacheInvalidationTracker + + tracker = CacheInvalidationTracker(memory_cache_manager) + + tracker.add_invalidation("model", "id1", bind_group="shard_a") + tracker.add_invalidation("model", "id2", bind_group="shard_b") + + # Verify pending invalidations exist + assert len(tracker._pending_invalidations) == 2 + assert len(tracker._pending_model_bumps) == 1 + + # Rollback should clear everything + tracker.rollback() + + assert len(tracker._pending_invalidations) == 0 + assert len(tracker._pending_model_bumps) == 0 + + +# ============================================================================ +# Async Repository Tests with bind_group +# ============================================================================ + + +@pytest.mark.asyncio +@pytest.mark.aiosqlite +@pytest.mark.skipif(not DOGPILE_CACHE_INSTALLED, reason="dogpile.cache not installed") +async def test_async_repository_get_with_bind_group_uses_separate_cache( + aiosqlite_engine: AsyncEngine, + memory_cache_manager: CacheManager, + request: pytest.FixtureRequest, +) -> None: + """Test async repository get() with bind_group uses separate cache entries.""" + from sqlalchemy.ext.asyncio import AsyncSession as AS + from sqlalchemy.ext.asyncio import async_sessionmaker + + worker_id = get_worker_id(request) + BindGroupAuthor = get_bind_group_author_model("aiosqlite_bind", worker_id) + + async with aiosqlite_engine.begin() as conn: + await conn.run_sync(BindGroupAuthor.metadata.create_all) + + try: + async_session_factory = async_sessionmaker(aiosqlite_engine, class_=AS, expire_on_commit=False) + async with async_session_factory() as session: + + class BindGroupAuthorRepository(SQLAlchemyAsyncRepository[Any]): + model_type = BindGroupAuthor + + repo = BindGroupAuthorRepository(session=session, cache_manager=memory_cache_manager, auto_expunge=True) + + # Create an author + author = BindGroupAuthor(name="Test Author", bio="Bio") + await repo.add(author) + await session.commit() + + author_id = author.id + table_name = BindGroupAuthor.__tablename__ + + # Get without bind_group - should cache to default key + author1 = await repo.get(author_id) + assert author1.name == "Test Author" + + # Verify default cache was populated + cached_default = memory_cache_manager.get_entity_sync(table_name, author_id, BindGroupAuthor, bind_group=None) + assert cached_default is not None + + # Get with bind_group="shard_a" - should cache to shard_a key + author2 = await repo.get(author_id, bind_group="shard_a") + assert author2.name == "Test Author" + + # Verify shard_a cache was populated + cached_shard_a = memory_cache_manager.get_entity_sync(table_name, author_id, BindGroupAuthor, bind_group="shard_a") + assert cached_shard_a is not None + + # Invalidate only shard_a cache + memory_cache_manager.invalidate_entity_sync(table_name, author_id, bind_group="shard_a") + + # Verify default cache still exists + cached_default_after = memory_cache_manager.get_entity_sync(table_name, author_id, BindGroupAuthor, bind_group=None) + assert cached_default_after is not None, "Default cache should still exist" + + # Verify shard_a cache was invalidated + cached_shard_a_after = memory_cache_manager.get_entity_sync(table_name, author_id, BindGroupAuthor, bind_group="shard_a") + assert cached_shard_a_after is None, "shard_a cache should be invalidated" + + finally: + async with aiosqlite_engine.begin() as conn: + await conn.run_sync(BindGroupAuthor.metadata.drop_all) + + +@pytest.mark.asyncio +@pytest.mark.aiosqlite +@pytest.mark.skipif(not DOGPILE_CACHE_INSTALLED, reason="dogpile.cache not installed") +async def test_async_repository_singleflight_key_includes_bind_group( + aiosqlite_engine: AsyncEngine, + memory_cache_manager: CacheManager, + request: pytest.FixtureRequest, +) -> None: + """Test that singleflight deduplication includes bind_group in key.""" + from sqlalchemy.ext.asyncio import AsyncSession as AS + from sqlalchemy.ext.asyncio import async_sessionmaker + + worker_id = get_worker_id(request) + BindGroupAuthor = get_bind_group_author_model("aiosqlite_sf", worker_id) + + async with aiosqlite_engine.begin() as conn: + await conn.run_sync(BindGroupAuthor.metadata.create_all) + + query_count = 0 + + def before_cursor_execute(_conn: object, _cursor: object, statement: str, *_: object) -> None: + nonlocal query_count + if statement.lstrip().upper().startswith("SELECT"): + query_count += 1 + + event.listen(aiosqlite_engine.sync_engine, "before_cursor_execute", before_cursor_execute) + + try: + async_session_factory = async_sessionmaker(aiosqlite_engine, class_=AS, expire_on_commit=False) + async with async_session_factory() as session: + + class BindGroupAuthorRepository(SQLAlchemyAsyncRepository[Any]): + model_type = BindGroupAuthor + + repo = BindGroupAuthorRepository(session=session, cache_manager=memory_cache_manager, auto_expunge=True) + + author = BindGroupAuthor(name="SF Author") + await repo.add(author) + await session.commit() + + author_id = author.id + table_name = BindGroupAuthor.__tablename__ + + # Invalidate cache to force misses + memory_cache_manager.invalidate_entity_sync(table_name, author_id, bind_group=None) + memory_cache_manager.invalidate_entity_sync(table_name, author_id, bind_group="shard_a") + + # Concurrent gets without bind_group should coalesce + query_count = 0 + results_default = await asyncio.gather(*[repo.get(author_id) for _ in range(5)]) + assert all(r.id == author_id for r in results_default) + queries_default = query_count + + # Concurrent gets with bind_group="shard_a" should coalesce separately + query_count = 0 + results_shard_a = await asyncio.gather(*[repo.get(author_id, bind_group="shard_a") for _ in range(5)]) + assert all(r.id == author_id for r in results_shard_a) + queries_shard_a = query_count + + # Both should have coalesced to 1 query each + assert queries_default == 1, "Default queries should coalesce" + assert queries_shard_a == 1, "shard_a queries should coalesce" + + finally: + event.remove(aiosqlite_engine.sync_engine, "before_cursor_execute", before_cursor_execute) + async with aiosqlite_engine.begin() as conn: + await conn.run_sync(BindGroupAuthor.metadata.drop_all) + + +@pytest.mark.asyncio +@pytest.mark.aiosqlite +@pytest.mark.skipif(not DOGPILE_CACHE_INSTALLED, reason="dogpile.cache not installed") +async def test_async_repository_get_bypasses_cache_when_disabled( + aiosqlite_engine: AsyncEngine, + memory_cache_manager: CacheManager, + request: pytest.FixtureRequest, +) -> None: + """Test async repository get() with use_cache=False bypasses cache regardless of bind_group.""" + from sqlalchemy.ext.asyncio import AsyncSession as AS + from sqlalchemy.ext.asyncio import async_sessionmaker + + worker_id = get_worker_id(request) + BindGroupAuthor = get_bind_group_author_model("aiosqlite_bypass", worker_id) + + async with aiosqlite_engine.begin() as conn: + await conn.run_sync(BindGroupAuthor.metadata.create_all) + + try: + async_session_factory = async_sessionmaker(aiosqlite_engine, class_=AS, expire_on_commit=False) + async with async_session_factory() as session: + + class BindGroupAuthorRepository(SQLAlchemyAsyncRepository[Any]): + model_type = BindGroupAuthor + + repo = BindGroupAuthorRepository(session=session, cache_manager=memory_cache_manager, auto_expunge=True) + + author = BindGroupAuthor(name="No Cache Author") + await repo.add(author) + await session.commit() + + author_id = author.id + table_name = BindGroupAuthor.__tablename__ + + # Get with cache disabled and bind_group + await repo.get(author_id, use_cache=False, bind_group="shard_a") + + # Cache should not be populated + cached = memory_cache_manager.get_entity_sync(table_name, author_id, BindGroupAuthor, bind_group="shard_a") + assert cached is None, "Cache should not be populated when use_cache=False" + + finally: + async with aiosqlite_engine.begin() as conn: + await conn.run_sync(BindGroupAuthor.metadata.drop_all) + + +# ============================================================================ +# Sync Repository Tests with bind_group +# ============================================================================ + + +@pytest.mark.sqlite +@pytest.mark.skipif(not DOGPILE_CACHE_INSTALLED, reason="dogpile.cache not installed") +def test_sync_repository_get_with_bind_group_uses_separate_cache( + sqlite_engine: Engine, + memory_cache_manager: CacheManager, + request: pytest.FixtureRequest, +) -> None: + """Test sync repository get() with bind_group uses separate cache entries.""" + from sqlalchemy.orm import sessionmaker + + worker_id = get_worker_id(request) + BindGroupAuthor = get_bind_group_author_model("sqlite_bind", worker_id) + + BindGroupAuthor.metadata.create_all(sqlite_engine) + + try: + session_factory = sessionmaker(sqlite_engine) + with session_factory() as session: + + class BindGroupAuthorRepository(SQLAlchemySyncRepository[Any]): + model_type = BindGroupAuthor + + repo = BindGroupAuthorRepository(session=session, cache_manager=memory_cache_manager, auto_expunge=True) + + # Create an author + author = BindGroupAuthor(name="Test Author", bio="Bio") + repo.add(author) + session.commit() + + author_id = author.id + table_name = BindGroupAuthor.__tablename__ + + # Get without bind_group + author1 = repo.get(author_id) + assert author1.name == "Test Author" + + # Verify default cache was populated + cached_default = memory_cache_manager.get_entity_sync(table_name, author_id, BindGroupAuthor, bind_group=None) + assert cached_default is not None + + # Get with bind_group="shard_a" + author2 = repo.get(author_id, bind_group="shard_a") + assert author2.name == "Test Author" + + # Verify shard_a cache was populated + cached_shard_a = memory_cache_manager.get_entity_sync(table_name, author_id, BindGroupAuthor, bind_group="shard_a") + assert cached_shard_a is not None + + # Invalidate only shard_a + memory_cache_manager.invalidate_entity_sync(table_name, author_id, bind_group="shard_a") + + # Verify default still cached, shard_a invalidated + assert memory_cache_manager.get_entity_sync(table_name, author_id, BindGroupAuthor, bind_group=None) is not None + assert memory_cache_manager.get_entity_sync(table_name, author_id, BindGroupAuthor, bind_group="shard_a") is None + + finally: + BindGroupAuthor.metadata.drop_all(sqlite_engine) + + +@pytest.mark.sqlite +@pytest.mark.skipif(not DOGPILE_CACHE_INSTALLED, reason="dogpile.cache not installed") +def test_sync_repository_get_bypasses_cache_when_disabled( + sqlite_engine: Engine, + memory_cache_manager: CacheManager, + request: pytest.FixtureRequest, +) -> None: + """Test sync repository get() with use_cache=False bypasses cache regardless of bind_group.""" + from sqlalchemy.orm import sessionmaker + + worker_id = get_worker_id(request) + BindGroupAuthor = get_bind_group_author_model("sqlite_bypass", worker_id) + + BindGroupAuthor.metadata.create_all(sqlite_engine) + + try: + session_factory = sessionmaker(sqlite_engine) + with session_factory() as session: + + class BindGroupAuthorRepository(SQLAlchemySyncRepository[Any]): + model_type = BindGroupAuthor + + repo = BindGroupAuthorRepository(session=session, cache_manager=memory_cache_manager, auto_expunge=True) + + author = BindGroupAuthor(name="No Cache") + repo.add(author) + session.commit() + + author_id = author.id + table_name = BindGroupAuthor.__tablename__ + + # Get with cache disabled and bind_group + repo.get(author_id, use_cache=False, bind_group="shard_a") + + # Cache should not be populated + cached = memory_cache_manager.get_entity_sync(table_name, author_id, BindGroupAuthor, bind_group="shard_a") + assert cached is None, "Cache should not be populated when use_cache=False" + + finally: + BindGroupAuthor.metadata.drop_all(sqlite_engine) + + +# ============================================================================ +# Repository default bind_group Tests +# ============================================================================ + + +@pytest.mark.asyncio +@pytest.mark.aiosqlite +@pytest.mark.skipif(not DOGPILE_CACHE_INSTALLED, reason="dogpile.cache not installed") +async def test_async_repository_uses_default_bind_group_for_cache( + aiosqlite_engine: AsyncEngine, + memory_cache_manager: CacheManager, + request: pytest.FixtureRequest, +) -> None: + """Test that repository uses default bind_group from constructor for cache keys.""" + from sqlalchemy.ext.asyncio import AsyncSession as AS + from sqlalchemy.ext.asyncio import async_sessionmaker + + worker_id = get_worker_id(request) + BindGroupAuthor = get_bind_group_author_model("aiosqlite_default", worker_id) + + async with aiosqlite_engine.begin() as conn: + await conn.run_sync(BindGroupAuthor.metadata.create_all) + + try: + async_session_factory = async_sessionmaker(aiosqlite_engine, class_=AS, expire_on_commit=False) + async with async_session_factory() as session: + + class BindGroupAuthorRepository(SQLAlchemyAsyncRepository[Any]): + model_type = BindGroupAuthor + + # Create repo with default bind_group via constructor parameter + repo = BindGroupAuthorRepository( + session=session, + cache_manager=memory_cache_manager, + auto_expunge=True, + bind_group="default_shard", + ) + + author = BindGroupAuthor(name="Default Shard Author") + await repo.add(author) + await session.commit() + + author_id = author.id + table_name = BindGroupAuthor.__tablename__ + + # Get without explicit bind_group - should use default from constructor + await repo.get(author_id) + + # Verify cache was populated for default_shard bind_group + cached = memory_cache_manager.get_entity_sync(table_name, author_id, BindGroupAuthor, bind_group="default_shard") + assert cached is not None, "Cache should use default bind_group from constructor" + + # Verify no cache for None bind_group + cached_none = memory_cache_manager.get_entity_sync(table_name, author_id, BindGroupAuthor, bind_group=None) + assert cached_none is None, "No cache should exist for None bind_group" + + finally: + async with aiosqlite_engine.begin() as conn: + await conn.run_sync(BindGroupAuthor.metadata.drop_all) + + +@pytest.mark.asyncio +@pytest.mark.aiosqlite +@pytest.mark.skipif(not DOGPILE_CACHE_INSTALLED, reason="dogpile.cache not installed") +async def test_async_repository_explicit_bind_group_overrides_default( + aiosqlite_engine: AsyncEngine, + memory_cache_manager: CacheManager, + request: pytest.FixtureRequest, +) -> None: + """Test that explicit bind_group parameter overrides repository default.""" + from sqlalchemy.ext.asyncio import AsyncSession as AS + from sqlalchemy.ext.asyncio import async_sessionmaker + + worker_id = get_worker_id(request) + BindGroupAuthor = get_bind_group_author_model("aiosqlite_override", worker_id) + + async with aiosqlite_engine.begin() as conn: + await conn.run_sync(BindGroupAuthor.metadata.create_all) + + try: + async_session_factory = async_sessionmaker(aiosqlite_engine, class_=AS, expire_on_commit=False) + async with async_session_factory() as session: + + class BindGroupAuthorRepository(SQLAlchemyAsyncRepository[Any]): + model_type = BindGroupAuthor + + # Create repo with default bind_group via constructor + repo = BindGroupAuthorRepository( + session=session, + cache_manager=memory_cache_manager, + auto_expunge=True, + bind_group="default_shard", + ) + + author = BindGroupAuthor(name="Override Test") + await repo.add(author) + await session.commit() + + author_id = author.id + table_name = BindGroupAuthor.__tablename__ + + # Get with explicit bind_group override + await repo.get(author_id, bind_group="override_shard") + + # Verify cache was populated for override_shard, not default_shard + cached_override = memory_cache_manager.get_entity_sync(table_name, author_id, BindGroupAuthor, bind_group="override_shard") + cached_default = memory_cache_manager.get_entity_sync(table_name, author_id, BindGroupAuthor, bind_group="default_shard") + + assert cached_override is not None, "Cache should be for override_shard" + assert cached_default is None, "No cache for default_shard when overridden" + + finally: + async with aiosqlite_engine.begin() as conn: + await conn.run_sync(BindGroupAuthor.metadata.drop_all) + + +# ============================================================================ +# Cache Manager Direct Tests (using model instances) +# ============================================================================ + + +@pytest.mark.asyncio +@pytest.mark.aiosqlite +@pytest.mark.skipif(not DOGPILE_CACHE_INSTALLED, reason="dogpile.cache not installed") +async def test_cache_manager_entity_methods_with_bind_group( + aiosqlite_engine: AsyncEngine, + memory_cache_manager: CacheManager, + request: pytest.FixtureRequest, +) -> None: + """Test CacheManager entity methods correctly handle bind_group in cache keys.""" + from sqlalchemy.ext.asyncio import AsyncSession as AS + from sqlalchemy.ext.asyncio import async_sessionmaker + + worker_id = get_worker_id(request) + BindGroupAuthor = get_bind_group_author_model("aiosqlite_cm", worker_id) + + async with aiosqlite_engine.begin() as conn: + await conn.run_sync(BindGroupAuthor.metadata.create_all) + + try: + async_session_factory = async_sessionmaker(aiosqlite_engine, class_=AS, expire_on_commit=False) + async with async_session_factory() as session: + # Create a model instance + author = BindGroupAuthor(name="Cache Manager Test") + session.add(author) + await session.commit() + + author_id = author.id + table_name = BindGroupAuthor.__tablename__ + + # Test set_entity_sync with different bind_groups + memory_cache_manager.set_entity_sync(table_name, author_id, author, bind_group=None) + + # Modify for shard_a (simulate different data in shard) + author.name = "Shard A Data" + memory_cache_manager.set_entity_sync(table_name, author_id, author, bind_group="shard_a") + + # Modify for shard_b + author.name = "Shard B Data" + memory_cache_manager.set_entity_sync(table_name, author_id, author, bind_group="shard_b") + + # Test get_entity_sync returns correct cached entity per bind_group + cached_default = memory_cache_manager.get_entity_sync(table_name, author_id, BindGroupAuthor, bind_group=None) + cached_shard_a = memory_cache_manager.get_entity_sync(table_name, author_id, BindGroupAuthor, bind_group="shard_a") + cached_shard_b = memory_cache_manager.get_entity_sync(table_name, author_id, BindGroupAuthor, bind_group="shard_b") + + assert cached_default is not None + assert cached_shard_a is not None + assert cached_shard_b is not None + + # Names should reflect what was cached at the time + assert cached_default.name == "Cache Manager Test" + assert cached_shard_a.name == "Shard A Data" + assert cached_shard_b.name == "Shard B Data" + + # Test invalidate_entity_sync only affects specific bind_group + memory_cache_manager.invalidate_entity_sync(table_name, author_id, bind_group="shard_a") + + # Verify only shard_a was invalidated + assert memory_cache_manager.get_entity_sync(table_name, author_id, BindGroupAuthor, bind_group=None) is not None + assert memory_cache_manager.get_entity_sync(table_name, author_id, BindGroupAuthor, bind_group="shard_a") is None + assert memory_cache_manager.get_entity_sync(table_name, author_id, BindGroupAuthor, bind_group="shard_b") is not None + + finally: + async with aiosqlite_engine.begin() as conn: + await conn.run_sync(BindGroupAuthor.metadata.drop_all) + + +@pytest.mark.asyncio +@pytest.mark.aiosqlite +@pytest.mark.skipif(not DOGPILE_CACHE_INSTALLED, reason="dogpile.cache not installed") +async def test_cache_manager_async_entity_methods_with_bind_group( + aiosqlite_engine: AsyncEngine, + memory_cache_manager: CacheManager, + request: pytest.FixtureRequest, +) -> None: + """Test CacheManager async entity methods correctly handle bind_group in cache keys.""" + from sqlalchemy.ext.asyncio import AsyncSession as AS + from sqlalchemy.ext.asyncio import async_sessionmaker + + worker_id = get_worker_id(request) + BindGroupAuthor = get_bind_group_author_model("aiosqlite_cm_async", worker_id) + + async with aiosqlite_engine.begin() as conn: + await conn.run_sync(BindGroupAuthor.metadata.create_all) + + try: + async_session_factory = async_sessionmaker(aiosqlite_engine, class_=AS, expire_on_commit=False) + async with async_session_factory() as session: + author = BindGroupAuthor(name="Async Cache Test") + session.add(author) + await session.commit() + + author_id = author.id + table_name = BindGroupAuthor.__tablename__ + + # Test async set/get with bind_groups + await memory_cache_manager.set_entity_async(table_name, author_id, author, bind_group=None) + + author.name = "Shard A Async" + await memory_cache_manager.set_entity_async(table_name, author_id, author, bind_group="shard_a") + + # Verify both cached correctly + cached_default = await memory_cache_manager.get_entity_async(table_name, author_id, BindGroupAuthor, bind_group=None) + cached_shard_a = await memory_cache_manager.get_entity_async(table_name, author_id, BindGroupAuthor, bind_group="shard_a") + + assert cached_default is not None + assert cached_shard_a is not None + assert cached_default.name == "Async Cache Test" + assert cached_shard_a.name == "Shard A Async" + + # Test async invalidation + await memory_cache_manager.invalidate_entity_async(table_name, author_id, bind_group="shard_a") + + # Verify only shard_a was invalidated + assert await memory_cache_manager.get_entity_async(table_name, author_id, BindGroupAuthor, bind_group=None) is not None + assert await memory_cache_manager.get_entity_async(table_name, author_id, BindGroupAuthor, bind_group="shard_a") is None + + finally: + async with aiosqlite_engine.begin() as conn: + await conn.run_sync(BindGroupAuthor.metadata.drop_all) + + +# ============================================================================ +# Cache Invalidation Tracker with Commit Tests +# ============================================================================ + + +@pytest.mark.asyncio +@pytest.mark.aiosqlite +@pytest.mark.skipif(not DOGPILE_CACHE_INSTALLED, reason="dogpile.cache not installed") +async def test_cache_invalidation_tracker_commit_with_bind_group( + aiosqlite_engine: AsyncEngine, + memory_cache_manager: CacheManager, + request: pytest.FixtureRequest, +) -> None: + """Test CacheInvalidationTracker commit properly invalidates with bind_group.""" + from sqlalchemy.ext.asyncio import AsyncSession as AS + from sqlalchemy.ext.asyncio import async_sessionmaker + + from advanced_alchemy._listeners import CacheInvalidationTracker + + worker_id = get_worker_id(request) + BindGroupAuthor = get_bind_group_author_model("aiosqlite_tracker", worker_id) + + async with aiosqlite_engine.begin() as conn: + await conn.run_sync(BindGroupAuthor.metadata.create_all) + + try: + async_session_factory = async_sessionmaker(aiosqlite_engine, class_=AS, expire_on_commit=False) + async with async_session_factory() as session: + author = BindGroupAuthor(name="Tracker Test") + session.add(author) + await session.commit() + + author_id = author.id + table_name = BindGroupAuthor.__tablename__ + + # Pre-populate cache for different bind_groups + memory_cache_manager.set_entity_sync(table_name, author_id, author, bind_group=None) + memory_cache_manager.set_entity_sync(table_name, author_id, author, bind_group="shard_a") + memory_cache_manager.set_entity_sync(table_name, author_id, author, bind_group="shard_b") + + # Create tracker and add invalidation for shard_a only + tracker = CacheInvalidationTracker(memory_cache_manager) + tracker.add_invalidation(table_name, author_id, bind_group="shard_a") + + # Commit the tracker (sync) + tracker.commit() + + # Verify only shard_a was invalidated + assert memory_cache_manager.get_entity_sync(table_name, author_id, BindGroupAuthor, bind_group=None) is not None + assert memory_cache_manager.get_entity_sync(table_name, author_id, BindGroupAuthor, bind_group="shard_a") is None + assert memory_cache_manager.get_entity_sync(table_name, author_id, BindGroupAuthor, bind_group="shard_b") is not None + + finally: + async with aiosqlite_engine.begin() as conn: + await conn.run_sync(BindGroupAuthor.metadata.drop_all) + + +@pytest.mark.asyncio +@pytest.mark.aiosqlite +@pytest.mark.skipif(not DOGPILE_CACHE_INSTALLED, reason="dogpile.cache not installed") +async def test_cache_invalidation_tracker_async_commit_with_bind_group( + aiosqlite_engine: AsyncEngine, + memory_cache_manager: CacheManager, + request: pytest.FixtureRequest, +) -> None: + """Test CacheInvalidationTracker async commit properly invalidates with bind_group.""" + from sqlalchemy.ext.asyncio import AsyncSession as AS + from sqlalchemy.ext.asyncio import async_sessionmaker + + from advanced_alchemy._listeners import CacheInvalidationTracker + + worker_id = get_worker_id(request) + BindGroupAuthor = get_bind_group_author_model("aiosqlite_tracker_async", worker_id) + + async with aiosqlite_engine.begin() as conn: + await conn.run_sync(BindGroupAuthor.metadata.create_all) + + try: + async_session_factory = async_sessionmaker(aiosqlite_engine, class_=AS, expire_on_commit=False) + async with async_session_factory() as session: + author = BindGroupAuthor(name="Async Tracker Test") + session.add(author) + await session.commit() + + author_id = author.id + table_name = BindGroupAuthor.__tablename__ + + # Pre-populate cache + await memory_cache_manager.set_entity_async(table_name, author_id, author, bind_group=None) + await memory_cache_manager.set_entity_async(table_name, author_id, author, bind_group="shard_a") + + # Create tracker and add invalidation + tracker = CacheInvalidationTracker(memory_cache_manager) + tracker.add_invalidation(table_name, author_id, bind_group="shard_a") + + # Async commit + await tracker.commit_async() + + # Verify only shard_a was invalidated + assert await memory_cache_manager.get_entity_async(table_name, author_id, BindGroupAuthor, bind_group=None) is not None + assert await memory_cache_manager.get_entity_async(table_name, author_id, BindGroupAuthor, bind_group="shard_a") is None + + finally: + async with aiosqlite_engine.begin() as conn: + await conn.run_sync(BindGroupAuthor.metadata.drop_all) From bc110ef0450db1b7343549f3ca6a31b317fb29d9 Mon Sep 17 00:00:00 2001 From: Cody Fincher Date: Sun, 18 Jan 2026 05:00:32 +0000 Subject: [PATCH 07/10] chore: Remove unnecessary `type: ignore` comments from `dogpile.cache` imports and reformat long lines in cache integration tests for readability. --- advanced_alchemy/cache/manager.py | 4 +- tests/integration/test_cache_bind_group.py | 120 ++++++++++++++++----- 2 files changed, 96 insertions(+), 28 deletions(-) diff --git a/advanced_alchemy/cache/manager.py b/advanced_alchemy/cache/manager.py index e53e43c02..6bc6f0dab 100644 --- a/advanced_alchemy/cache/manager.py +++ b/advanced_alchemy/cache/manager.py @@ -45,10 +45,10 @@ def _make_region_stub() -> SyncCacheRegionProtocol: _make_region: MakeRegionFunc _dogpile_no_value: object try: - from dogpile.cache import ( # type: ignore[import-not-found] # pyright: ignore[reportMissingImports] + from dogpile.cache import ( # pyright: ignore[reportMissingImports] make_region as _make_region_real, # pyright: ignore[reportUnknownVariableType] ) - from dogpile.cache.api import ( # type: ignore[import-not-found] # pyright: ignore[reportMissingImports] + from dogpile.cache.api import ( # pyright: ignore[reportMissingImports] NO_VALUE as _dogpile_no_value_real, # noqa: N811 # pyright: ignore[reportUnknownVariableType] ) diff --git a/tests/integration/test_cache_bind_group.py b/tests/integration/test_cache_bind_group.py index 39fa26847..3df0a896b 100644 --- a/tests/integration/test_cache_bind_group.py +++ b/tests/integration/test_cache_bind_group.py @@ -186,7 +186,9 @@ class BindGroupAuthorRepository(SQLAlchemyAsyncRepository[Any]): assert author1.name == "Test Author" # Verify default cache was populated - cached_default = memory_cache_manager.get_entity_sync(table_name, author_id, BindGroupAuthor, bind_group=None) + cached_default = memory_cache_manager.get_entity_sync( + table_name, author_id, BindGroupAuthor, bind_group=None + ) assert cached_default is not None # Get with bind_group="shard_a" - should cache to shard_a key @@ -194,18 +196,24 @@ class BindGroupAuthorRepository(SQLAlchemyAsyncRepository[Any]): assert author2.name == "Test Author" # Verify shard_a cache was populated - cached_shard_a = memory_cache_manager.get_entity_sync(table_name, author_id, BindGroupAuthor, bind_group="shard_a") + cached_shard_a = memory_cache_manager.get_entity_sync( + table_name, author_id, BindGroupAuthor, bind_group="shard_a" + ) assert cached_shard_a is not None # Invalidate only shard_a cache memory_cache_manager.invalidate_entity_sync(table_name, author_id, bind_group="shard_a") # Verify default cache still exists - cached_default_after = memory_cache_manager.get_entity_sync(table_name, author_id, BindGroupAuthor, bind_group=None) + cached_default_after = memory_cache_manager.get_entity_sync( + table_name, author_id, BindGroupAuthor, bind_group=None + ) assert cached_default_after is not None, "Default cache should still exist" # Verify shard_a cache was invalidated - cached_shard_a_after = memory_cache_manager.get_entity_sync(table_name, author_id, BindGroupAuthor, bind_group="shard_a") + cached_shard_a_after = memory_cache_manager.get_entity_sync( + table_name, author_id, BindGroupAuthor, bind_group="shard_a" + ) assert cached_shard_a_after is None, "shard_a cache should be invalidated" finally: @@ -370,7 +378,9 @@ class BindGroupAuthorRepository(SQLAlchemySyncRepository[Any]): assert author1.name == "Test Author" # Verify default cache was populated - cached_default = memory_cache_manager.get_entity_sync(table_name, author_id, BindGroupAuthor, bind_group=None) + cached_default = memory_cache_manager.get_entity_sync( + table_name, author_id, BindGroupAuthor, bind_group=None + ) assert cached_default is not None # Get with bind_group="shard_a" @@ -378,15 +388,23 @@ class BindGroupAuthorRepository(SQLAlchemySyncRepository[Any]): assert author2.name == "Test Author" # Verify shard_a cache was populated - cached_shard_a = memory_cache_manager.get_entity_sync(table_name, author_id, BindGroupAuthor, bind_group="shard_a") + cached_shard_a = memory_cache_manager.get_entity_sync( + table_name, author_id, BindGroupAuthor, bind_group="shard_a" + ) assert cached_shard_a is not None # Invalidate only shard_a memory_cache_manager.invalidate_entity_sync(table_name, author_id, bind_group="shard_a") # Verify default still cached, shard_a invalidated - assert memory_cache_manager.get_entity_sync(table_name, author_id, BindGroupAuthor, bind_group=None) is not None - assert memory_cache_manager.get_entity_sync(table_name, author_id, BindGroupAuthor, bind_group="shard_a") is None + assert ( + memory_cache_manager.get_entity_sync(table_name, author_id, BindGroupAuthor, bind_group=None) + is not None + ) + assert ( + memory_cache_manager.get_entity_sync(table_name, author_id, BindGroupAuthor, bind_group="shard_a") + is None + ) finally: BindGroupAuthor.metadata.drop_all(sqlite_engine) @@ -483,7 +501,9 @@ class BindGroupAuthorRepository(SQLAlchemyAsyncRepository[Any]): await repo.get(author_id) # Verify cache was populated for default_shard bind_group - cached = memory_cache_manager.get_entity_sync(table_name, author_id, BindGroupAuthor, bind_group="default_shard") + cached = memory_cache_manager.get_entity_sync( + table_name, author_id, BindGroupAuthor, bind_group="default_shard" + ) assert cached is not None, "Cache should use default bind_group from constructor" # Verify no cache for None bind_group @@ -539,8 +559,12 @@ class BindGroupAuthorRepository(SQLAlchemyAsyncRepository[Any]): await repo.get(author_id, bind_group="override_shard") # Verify cache was populated for override_shard, not default_shard - cached_override = memory_cache_manager.get_entity_sync(table_name, author_id, BindGroupAuthor, bind_group="override_shard") - cached_default = memory_cache_manager.get_entity_sync(table_name, author_id, BindGroupAuthor, bind_group="default_shard") + cached_override = memory_cache_manager.get_entity_sync( + table_name, author_id, BindGroupAuthor, bind_group="override_shard" + ) + cached_default = memory_cache_manager.get_entity_sync( + table_name, author_id, BindGroupAuthor, bind_group="default_shard" + ) assert cached_override is not None, "Cache should be for override_shard" assert cached_default is None, "No cache for default_shard when overridden" @@ -596,9 +620,15 @@ async def test_cache_manager_entity_methods_with_bind_group( memory_cache_manager.set_entity_sync(table_name, author_id, author, bind_group="shard_b") # Test get_entity_sync returns correct cached entity per bind_group - cached_default = memory_cache_manager.get_entity_sync(table_name, author_id, BindGroupAuthor, bind_group=None) - cached_shard_a = memory_cache_manager.get_entity_sync(table_name, author_id, BindGroupAuthor, bind_group="shard_a") - cached_shard_b = memory_cache_manager.get_entity_sync(table_name, author_id, BindGroupAuthor, bind_group="shard_b") + cached_default = memory_cache_manager.get_entity_sync( + table_name, author_id, BindGroupAuthor, bind_group=None + ) + cached_shard_a = memory_cache_manager.get_entity_sync( + table_name, author_id, BindGroupAuthor, bind_group="shard_a" + ) + cached_shard_b = memory_cache_manager.get_entity_sync( + table_name, author_id, BindGroupAuthor, bind_group="shard_b" + ) assert cached_default is not None assert cached_shard_a is not None @@ -613,9 +643,18 @@ async def test_cache_manager_entity_methods_with_bind_group( memory_cache_manager.invalidate_entity_sync(table_name, author_id, bind_group="shard_a") # Verify only shard_a was invalidated - assert memory_cache_manager.get_entity_sync(table_name, author_id, BindGroupAuthor, bind_group=None) is not None - assert memory_cache_manager.get_entity_sync(table_name, author_id, BindGroupAuthor, bind_group="shard_a") is None - assert memory_cache_manager.get_entity_sync(table_name, author_id, BindGroupAuthor, bind_group="shard_b") is not None + assert ( + memory_cache_manager.get_entity_sync(table_name, author_id, BindGroupAuthor, bind_group=None) + is not None + ) + assert ( + memory_cache_manager.get_entity_sync(table_name, author_id, BindGroupAuthor, bind_group="shard_a") + is None + ) + assert ( + memory_cache_manager.get_entity_sync(table_name, author_id, BindGroupAuthor, bind_group="shard_b") + is not None + ) finally: async with aiosqlite_engine.begin() as conn: @@ -657,8 +696,12 @@ async def test_cache_manager_async_entity_methods_with_bind_group( await memory_cache_manager.set_entity_async(table_name, author_id, author, bind_group="shard_a") # Verify both cached correctly - cached_default = await memory_cache_manager.get_entity_async(table_name, author_id, BindGroupAuthor, bind_group=None) - cached_shard_a = await memory_cache_manager.get_entity_async(table_name, author_id, BindGroupAuthor, bind_group="shard_a") + cached_default = await memory_cache_manager.get_entity_async( + table_name, author_id, BindGroupAuthor, bind_group=None + ) + cached_shard_a = await memory_cache_manager.get_entity_async( + table_name, author_id, BindGroupAuthor, bind_group="shard_a" + ) assert cached_default is not None assert cached_shard_a is not None @@ -669,8 +712,16 @@ async def test_cache_manager_async_entity_methods_with_bind_group( await memory_cache_manager.invalidate_entity_async(table_name, author_id, bind_group="shard_a") # Verify only shard_a was invalidated - assert await memory_cache_manager.get_entity_async(table_name, author_id, BindGroupAuthor, bind_group=None) is not None - assert await memory_cache_manager.get_entity_async(table_name, author_id, BindGroupAuthor, bind_group="shard_a") is None + assert ( + await memory_cache_manager.get_entity_async(table_name, author_id, BindGroupAuthor, bind_group=None) + is not None + ) + assert ( + await memory_cache_manager.get_entity_async( + table_name, author_id, BindGroupAuthor, bind_group="shard_a" + ) + is None + ) finally: async with aiosqlite_engine.begin() as conn: @@ -725,9 +776,18 @@ async def test_cache_invalidation_tracker_commit_with_bind_group( tracker.commit() # Verify only shard_a was invalidated - assert memory_cache_manager.get_entity_sync(table_name, author_id, BindGroupAuthor, bind_group=None) is not None - assert memory_cache_manager.get_entity_sync(table_name, author_id, BindGroupAuthor, bind_group="shard_a") is None - assert memory_cache_manager.get_entity_sync(table_name, author_id, BindGroupAuthor, bind_group="shard_b") is not None + assert ( + memory_cache_manager.get_entity_sync(table_name, author_id, BindGroupAuthor, bind_group=None) + is not None + ) + assert ( + memory_cache_manager.get_entity_sync(table_name, author_id, BindGroupAuthor, bind_group="shard_a") + is None + ) + assert ( + memory_cache_manager.get_entity_sync(table_name, author_id, BindGroupAuthor, bind_group="shard_b") + is not None + ) finally: async with aiosqlite_engine.begin() as conn: @@ -776,8 +836,16 @@ async def test_cache_invalidation_tracker_async_commit_with_bind_group( await tracker.commit_async() # Verify only shard_a was invalidated - assert await memory_cache_manager.get_entity_async(table_name, author_id, BindGroupAuthor, bind_group=None) is not None - assert await memory_cache_manager.get_entity_async(table_name, author_id, BindGroupAuthor, bind_group="shard_a") is None + assert ( + await memory_cache_manager.get_entity_async(table_name, author_id, BindGroupAuthor, bind_group=None) + is not None + ) + assert ( + await memory_cache_manager.get_entity_async( + table_name, author_id, BindGroupAuthor, bind_group="shard_a" + ) + is None + ) finally: async with aiosqlite_engine.begin() as conn: From 39ebafca50fb94aa9217d43e2b39732e679314cd Mon Sep 17 00:00:00 2001 From: Cody Fincher Date: Sun, 18 Jan 2026 05:04:23 +0000 Subject: [PATCH 08/10] feat: Add `dogpile-cache` as an optional dependency via a new `dogpile` extra, including its transitive dependencies `decorator` and `stevedore`. --- pyproject.toml | 10 ++---- uv.lock | 82 +++++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 84 insertions(+), 8 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 5afa760cb..eaf729c71 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -60,6 +60,7 @@ Source = "https://github.com/litestar-org/advanced-alchemy" [project.optional-dependencies] argon2 = ["argon2-cffi"] cli = ["rich-click"] +dogpile = ["dogpile.cache"] fsspec = ["fsspec"] nanoid = ["fastnanoid>=0.4.1"] obstore = ["obstore"] @@ -467,12 +468,7 @@ module = [ [[tool.mypy.overrides]] follow_imports = "skip" ignore_missing_imports = true -module = [ - "pytest", - "pytest.*", - "_pytest", - "_pytest.*", -] +module = ["pytest", "pytest.*", "_pytest", "_pytest.*"] [[tool.mypy.overrides]] module = "advanced_alchemy._serialization" @@ -562,6 +558,7 @@ SQLAlchemyAsyncRepository = "SQLAlchemySyncRepository" SQLAlchemyAsyncRepositoryProtocol = "SQLAlchemySyncRepositoryProtocol" "SQLAlchemyAsyncSlugRepository" = "SQLAlchemySyncSlugRepository" SQLAlchemyAsyncSlugRepositoryProtocol = "SQLAlchemySyncSlugRepositoryProtocol" +"async_scoped_session" = "scoped_session" "bump_model_version_async" = "bump_model_version_sync" "get_entity_async" = "get_entity_sync" "get_list_and_count_async" = "get_list_and_count_sync" @@ -572,7 +569,6 @@ SQLAlchemyAsyncSlugRepositoryProtocol = "SQLAlchemySyncSlugRepositoryProtocol" "set_list_and_count_async" = "set_list_and_count_sync" "set_list_async" = "set_list_sync" "singleflight_async" = "singleflight_sync" -"async_scoped_session" = "scoped_session" "sqlalchemy.ext.asyncio.AsyncSession" = "sqlalchemy.orm.Session" "sqlalchemy.ext.asyncio.scoping.async_scoped_session" = "sqlalchemy.orm.scoping.scoped_session" diff --git a/uv.lock b/uv.lock index 7f1bf972b..eec38ca0e 100644 --- a/uv.lock +++ b/uv.lock @@ -45,6 +45,10 @@ argon2 = [ cli = [ { name = "rich-click" }, ] +dogpile = [ + { name = "dogpile-cache", version = "1.4.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "dogpile-cache", version = "1.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, +] fsspec = [ { name = "fsspec", version = "2025.10.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, { name = "fsspec", version = "2026.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, @@ -312,6 +316,7 @@ test = [ requires-dist = [ { name = "alembic", specifier = ">=1.12.0" }, { name = "argon2-cffi", marker = "extra == 'argon2'" }, + { name = "dogpile-cache", marker = "extra == 'dogpile'" }, { name = "eval-type-backport", marker = "python_full_version < '3.10'" }, { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, { name = "fastnanoid", marker = "extra == 'nanoid'", specifier = ">=0.4.1" }, @@ -325,7 +330,7 @@ requires-dist = [ { name = "typing-extensions", specifier = ">=4.0.0" }, { name = "uuid-utils", marker = "extra == 'uuid'", specifier = ">=0.6.1" }, ] -provides-extras = ["argon2", "cli", "fsspec", "nanoid", "obstore", "passlib", "pwdlib", "uuid"] +provides-extras = ["argon2", "cli", "dogpile", "fsspec", "nanoid", "obstore", "passlib", "pwdlib", "uuid"] [package.metadata.requires-dev] build = [{ name = "bump-my-version" }] @@ -1916,6 +1921,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a7/ec/bb273b7208c606890dc36540fe667d06ce840a6f62f9fae7e658fcdc90fb/cssutils-2.11.1-py3-none-any.whl", hash = "sha256:a67bfdfdff4f3867fab43698ec4897c1a828eca5973f4073321b3bccaf1199b1", size = 385747, upload-time = "2024-06-04T15:51:37.499Z" }, ] +[[package]] +name = "decorator" +version = "5.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/fa/6d96a0978d19e17b68d634497769987b16c8f4cd0a7a05048bec693caa6b/decorator-5.2.1.tar.gz", hash = "sha256:65f266143752f734b0a7cc83c46f4618af75b8c5911b00ccb61d0ac9b6da0360", size = 56711, upload-time = "2025-02-24T04:41:34.073Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4e/8c/f3147f5c4b73e7550fe5f9352eaa956ae838d5c51eb58e7a25b9f3e2643b/decorator-5.2.1-py3-none-any.whl", hash = "sha256:d316bb415a2d9e2d2b3abcc4084c6502fc09240e292cd76a76afc106a1c8e04a", size = 9190, upload-time = "2025-02-24T04:41:32.565Z" }, +] + [[package]] name = "dict2css" version = "0.3.0.post1" @@ -2002,6 +2016,44 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8f/d7/9322c609343d929e75e7e5e6255e614fcc67572cfd083959cdef3b7aad79/docutils-0.21.2-py3-none-any.whl", hash = "sha256:dafca5b9e384f0e419294eb4d2ff9fa826435bf15f15b7bd45723e8ad76811b2", size = 587408, upload-time = "2024-04-23T18:57:14.835Z" }, ] +[[package]] +name = "dogpile-cache" +version = "1.4.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +dependencies = [ + { name = "decorator", marker = "python_full_version < '3.10'" }, + { name = "stevedore", version = "5.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "typing-extensions", marker = "python_full_version < '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/86/97/da72845c89c9aa70e3e74609b864eff5e5c2ec46366645e7bb61eaa29e9c/dogpile_cache-1.4.1.tar.gz", hash = "sha256:e25c60e677a5e28ff86124765fbf18c53257bcd7830749cd5ba350ace2a12989", size = 939952, upload-time = "2025-09-12T16:34:32.997Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d3/f6/1d9579d7b86e7957dd626e52b65c72af0fcd47b277716efd9889990d92b4/dogpile_cache-1.4.1-py3-none-any.whl", hash = "sha256:99130ce990800c8d89c26a5a8d9923cbe1b78c8a9972c2aaa0abf3d2ef2984ad", size = 63593, upload-time = "2025-09-12T16:34:34.809Z" }, +] + +[[package]] +name = "dogpile-cache" +version = "1.5.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14'", + "python_full_version == '3.13.*'", + "python_full_version == '3.12.*'", + "python_full_version == '3.11.*'", + "python_full_version == '3.10.*'", +] +dependencies = [ + { name = "decorator", marker = "python_full_version >= '3.10'" }, + { name = "stevedore", version = "5.6.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "typing-extensions", marker = "python_full_version == '3.10.*'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e7/c8/301ff89746e76745b937606df4753c032787c59ecb37dd4d4250bddc8929/dogpile_cache-1.5.0.tar.gz", hash = "sha256:849c5573c9a38f155cd4173103c702b637ede0361c12e864876877d0cd125eec", size = 947962, upload-time = "2025-10-11T17:35:36.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/80/12235e5b75bb2c586733280854f131b86051e0bbdfb55349ff70d0f72cf9/dogpile_cache-1.5.0-py3-none-any.whl", hash = "sha256:dc7b47d37844db15e8fdc0243c1b58857a2ddc52a5118237a97127bac200e18d", size = 64447, upload-time = "2025-10-11T17:35:38.573Z" }, +] + [[package]] name = "domdf-python-tools" version = "3.10.0" @@ -7655,6 +7707,34 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d9/52/1064f510b141bd54025f9b55105e26d1fa970b9be67ad766380a3c9b74b0/starlette-0.50.0-py3-none-any.whl", hash = "sha256:9e5391843ec9b6e472eed1365a78c8098cfceb7a74bfd4d6b1c0c0095efb3bca", size = 74033, upload-time = "2025-11-01T15:25:25.461Z" }, ] +[[package]] +name = "stevedore" +version = "5.5.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +sdist = { url = "https://files.pythonhosted.org/packages/2a/5f/8418daad5c353300b7661dd8ce2574b0410a6316a8be650a189d5c68d938/stevedore-5.5.0.tar.gz", hash = "sha256:d31496a4f4df9825e1a1e4f1f74d19abb0154aff311c3b376fcc89dae8fccd73", size = 513878, upload-time = "2025-08-25T12:54:26.806Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/80/c5/0c06759b95747882bb50abda18f5fb48c3e9b0fbfc6ebc0e23550b52415d/stevedore-5.5.0-py3-none-any.whl", hash = "sha256:18363d4d268181e8e8452e71a38cd77630f345b2ef6b4a8d5614dac5ee0d18cf", size = 49518, upload-time = "2025-08-25T12:54:25.445Z" }, +] + +[[package]] +name = "stevedore" +version = "5.6.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14'", + "python_full_version == '3.13.*'", + "python_full_version == '3.12.*'", + "python_full_version == '3.11.*'", + "python_full_version == '3.10.*'", +] +sdist = { url = "https://files.pythonhosted.org/packages/96/5b/496f8abebd10c3301129abba7ddafd46c71d799a70c44ab080323987c4c9/stevedore-5.6.0.tar.gz", hash = "sha256:f22d15c6ead40c5bbfa9ca54aa7e7b4a07d59b36ae03ed12ced1a54cf0b51945", size = 516074, upload-time = "2025-11-20T10:06:07.264Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/40/8561ce06dc46fd17242c7724ab25b257a2ac1b35f4ebf551b40ce6105cfa/stevedore-5.6.0-py3-none-any.whl", hash = "sha256:4a36dccefd7aeea0c70135526cecb7766c4c84c473b1af68db23d541b6dc1820", size = 54428, upload-time = "2025-11-20T10:06:05.946Z" }, +] + [[package]] name = "tabulate" version = "0.9.0" From 460e3d7155575e7797037615692e899efc77f11b Mon Sep 17 00:00:00 2001 From: Cody Fincher Date: Sun, 18 Jan 2026 05:05:36 +0000 Subject: [PATCH 09/10] feat: add dogpile-cache to project dependencies and lockfile. --- pyproject.toml | 1 + uv.lock | 6 ++++++ 2 files changed, 7 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index eaf729c71..3bc7a3061 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -151,6 +151,7 @@ test = [ "attrs", "cattrs", "dishka ; python_version >= \"3.10\"", + "dogpile.cache", "pydantic-extra-types", "numpy", "pgvector", diff --git a/uv.lock b/uv.lock index eec38ca0e..f2d0f1c04 100644 --- a/uv.lock +++ b/uv.lock @@ -98,6 +98,8 @@ dev = [ { name = "coverage", version = "7.10.7", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, { name = "coverage", version = "7.13.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, { name = "dishka", marker = "python_full_version >= '3.10'" }, + { name = "dogpile-cache", version = "1.4.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "dogpile-cache", version = "1.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, { name = "duckdb" }, { name = "duckdb-engine" }, { name = "fastapi", extra = ["all"] }, @@ -287,6 +289,8 @@ test = [ { name = "coverage", version = "7.10.7", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, { name = "coverage", version = "7.13.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, { name = "dishka", marker = "python_full_version >= '3.10'" }, + { name = "dogpile-cache", version = "1.4.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "dogpile-cache", version = "1.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, { name = "fsspec", version = "2025.10.0", source = { registry = "https://pypi.org/simple" }, extra = ["s3"], marker = "python_full_version < '3.10'" }, { name = "fsspec", version = "2026.1.0", source = { registry = "https://pypi.org/simple" }, extra = ["s3"], marker = "python_full_version >= '3.10'" }, { name = "numpy", version = "2.0.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, @@ -355,6 +359,7 @@ dev = [ { name = "click" }, { name = "coverage", specifier = ">=7.6.1" }, { name = "dishka", marker = "python_full_version >= '3.10'" }, + { name = "dogpile-cache" }, { name = "duckdb", specifier = ">=1.1.2" }, { name = "duckdb-engine", specifier = ">=0.13.4" }, { name = "fastapi", extras = ["all"], specifier = ">=0.115.3" }, @@ -498,6 +503,7 @@ test = [ { name = "click" }, { name = "coverage", specifier = ">=7.6.1" }, { name = "dishka", marker = "python_full_version >= '3.10'" }, + { name = "dogpile-cache" }, { name = "fsspec", extras = ["s3"] }, { name = "numpy" }, { name = "pgvector" }, From f76ebe328354a6c4c0dcd0f9b5d4cc3d9e9693a0 Mon Sep 17 00:00:00 2001 From: Cody Fincher Date: Sun, 18 Jan 2026 05:14:45 +0000 Subject: [PATCH 10/10] fix: Safely retrieve `__tablename__` for cache invalidation, preventing errors when the attribute is missing. --- advanced_alchemy/repository/_async.py | 7 +++++-- advanced_alchemy/repository/_sync.py | 7 +++++-- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/advanced_alchemy/repository/_async.py b/advanced_alchemy/repository/_async.py index 777961e97..11fc4dee1 100644 --- a/advanced_alchemy/repository/_async.py +++ b/advanced_alchemy/repository/_async.py @@ -713,10 +713,13 @@ def _queue_cache_invalidation(self, entity_id: Any, bind_group: Optional[str] = if self._cache_manager is not None: from advanced_alchemy._listeners import get_cache_tracker - model_name = cast("str", self.model_type.__tablename__) # type: ignore[attr-defined] + # Check if model_type has __tablename__ (may not exist in mock scenarios) + model_name = getattr(self.model_type, "__tablename__", None) + if model_name is None: + return tracker = get_cache_tracker(self.session, self._cache_manager) if tracker is not None: - tracker.add_invalidation(model_name, entity_id, bind_group) + tracker.add_invalidation(cast("str", model_name), entity_id, bind_group) def _type_must_use_in_instead_of_any(self, matched_values: "list[Any]", field_type: "Any" = None) -> bool: """Determine if field.in_() should be used instead of any_() for compatibility. diff --git a/advanced_alchemy/repository/_sync.py b/advanced_alchemy/repository/_sync.py index 42efa88f9..0d9b6351e 100644 --- a/advanced_alchemy/repository/_sync.py +++ b/advanced_alchemy/repository/_sync.py @@ -714,10 +714,13 @@ def _queue_cache_invalidation(self, entity_id: Any, bind_group: Optional[str] = if self._cache_manager is not None: from advanced_alchemy._listeners import get_cache_tracker - model_name = cast("str", self.model_type.__tablename__) # type: ignore[attr-defined] + # Check if model_type has __tablename__ (may not exist in mock scenarios) + model_name = getattr(self.model_type, "__tablename__", None) + if model_name is None: + return tracker = get_cache_tracker(self.session, self._cache_manager) if tracker is not None: - tracker.add_invalidation(model_name, entity_id, bind_group) + tracker.add_invalidation(cast("str", model_name), entity_id, bind_group) def _type_must_use_in_instead_of_any(self, matched_values: "list[Any]", field_type: "Any" = None) -> bool: """Determine if field.in_() should be used instead of any_() for compatibility.