From ae9ed89c78d76c860c9cf075c9a60b013c27b01d Mon Sep 17 00:00:00 2001 From: ran Date: Mon, 1 Dec 2025 16:03:35 +0800 Subject: [PATCH] feat: add LRU Cache --- README.md | 87 +++++++++++- action_dispatch/__init__.py | 2 + action_dispatch/action_dispatcher.py | 39 +++++- action_dispatch/mixins/__init__.py | 5 + action_dispatch/mixins/cache.py | 129 ++++++++++++++++++ tests/test_cache.py | 196 +++++++++++++++++++++++++++ 6 files changed, 454 insertions(+), 4 deletions(-) create mode 100644 action_dispatch/mixins/__init__.py create mode 100644 action_dispatch/mixins/cache.py create mode 100644 tests/test_cache.py diff --git a/README.md b/README.md index 4745ab0..877e1b6 100644 --- a/README.md +++ b/README.md @@ -114,6 +114,62 @@ except InvalidDimensionError as e: print(f"Available dimensions: {e.available_dimensions}") ``` +### Caching for Performance + +Enable LRU caching to improve performance when you have many dimensions or high-frequency lookups with repeated action + scope combinations. + +```python +# Enable cache on initialization +dispatcher = ActionDispatcher( + dimensions=['region', 'platform', 'version', 'tier'], + enable_cache=True, + cache_maxsize=512 +) + +@dispatcher.handler("get_data", region="asia", platform="mobile") +def get_data_asia_mobile(params): + return "data for asia mobile" + +# First lookup - cache miss +result = dispatcher.get_handler("get_data", region="asia", platform="mobile") + +# Second lookup - cache hit (faster) +result = dispatcher.get_handler("get_data", region="asia", platform="mobile") + +# Check cache statistics +info = dispatcher.cache_info() +print(f"Cache hits: {info['hits']}, misses: {info['misses']}") +# Output: Cache hits: 1, misses: 1 +``` + +#### Runtime Cache Control + +```python +# Create dispatcher without cache +dispatcher = ActionDispatcher(dimensions=['platform']) + +# Enable cache later +dispatcher.enable_cache(maxsize=256) +print(dispatcher.is_cache_enabled) # True + +# Clear cache manually (useful after bulk handler registration) +dispatcher.clear_cache() + +# Disable cache when no longer needed +dispatcher.disable_cache() +print(dispatcher.is_cache_enabled) # False +``` + +#### When to Use Caching + +| Scenario | Recommendation | +|----------|----------------| +| 2-5 dimensions | Cache optional | +| 5-10 dimensions | Consider enabling cache | +| 10+ dimensions | Recommend enabling cache | +| High QPS (>10K) | Recommend enabling cache | +| Dynamic handler registration | Cache auto-invalidates on registration | + ## Real-World Examples ### Web API with Role-Based Access Control @@ -195,10 +251,12 @@ result = plugin_dispatcher.dispatch(context, "transform_data", data=input_data) ### ActionDispatcher -#### `__init__(dimensions=None)` -Create a new dispatcher with optional dimensions. +#### `__init__(dimensions=None, enable_cache=False, cache_maxsize=256)` +Create a new dispatcher with optional dimensions and caching. - `dimensions` (list, optional): List of dimension names for routing +- `enable_cache` (bool, optional): Enable LRU cache for handler lookups (default: False) +- `cache_maxsize` (int, optional): Maximum size of the LRU cache (default: 256) #### `@handler(action, **kwargs)` Decorator to register a handler for specific action and dimensions. @@ -225,6 +283,31 @@ Dispatch an action based on context. - `action_name` (str): Action to dispatch - `**kwargs`: Additional parameters passed to handler +#### Cache Methods + +##### `enable_cache(maxsize=None)` +Enable LRU cache for handler lookups at runtime. + +- `maxsize` (int, optional): Maximum cache size (uses existing value if None) + +##### `disable_cache()` +Disable cache and clear cached data. + +##### `clear_cache()` +Clear all cached handler lookups. + +##### `cache_info()` +Get cache statistics. Returns `None` if cache is disabled. + +Returns a dict with: +- `hits`: Number of cache hits +- `misses`: Number of cache misses +- `maxsize`: Maximum cache size +- `currsize`: Current number of cached items + +##### `is_cache_enabled` +Property that returns `True` if cache is currently enabled. + ### Exceptions - `ActionDispatchError`: Base exception class diff --git a/action_dispatch/__init__.py b/action_dispatch/__init__.py index 58936f6..6799454 100644 --- a/action_dispatch/__init__.py +++ b/action_dispatch/__init__.py @@ -36,6 +36,7 @@ InvalidActionError, InvalidDimensionError, ) +from .mixins import CacheMixin __version__ = "0.1.1" __author__ = "Eowl" @@ -43,6 +44,7 @@ __all__ = [ "ActionDispatcher", + "CacheMixin", "ActionDispatchError", "InvalidDimensionError", "HandlerNotFoundError", diff --git a/action_dispatch/action_dispatcher.py b/action_dispatch/action_dispatcher.py index 392eb37..612b90f 100644 --- a/action_dispatch/action_dispatcher.py +++ b/action_dispatch/action_dispatcher.py @@ -3,6 +3,9 @@ from functools import partial from typing import Any, Callable, Optional, Union +from .mixins import CacheMixin +from .mixins.cache import DEFAULT_CACHE_MAXSIZE + try: from .exceptions import ( HandlerNotFoundError, @@ -13,12 +16,27 @@ pass -class ActionDispatcher: +class ActionDispatcher(CacheMixin): + """Action dispatcher with multi-dimensional routing and optional LRU cache.""" + dimensions: list[str] registry: dict[str, Any] global_handlers: dict[str, Callable[[dict[str, Any]], Any]] - def __init__(self, dimensions: Optional[list[str]] = None) -> None: + def __init__( + self, + dimensions: Optional[list[str]] = None, + enable_cache: bool = False, + cache_maxsize: int = DEFAULT_CACHE_MAXSIZE, + ) -> None: + """ + Initialize ActionDispatcher. + + Args: + dimensions: List of dimension names for routing. + enable_cache: Whether to enable LRU cache (default: False). + cache_maxsize: Maximum cache size (default: 256). + """ if dimensions is not None and not isinstance(dimensions, list): warnings.warn( f"ActionDispatcher dimensions should be a list, got " @@ -31,6 +49,9 @@ def __init__(self, dimensions: Optional[list[str]] = None) -> None: self.registry = self._create_nested_dict(len(self.dimensions)) self.global_handlers = {} + # Initialize cache from mixin + self._init_cache(enable_cache, cache_maxsize) + self._create_dynamic_methods() def _create_nested_dict( @@ -116,9 +137,19 @@ def _register_handler( current_level[action] = handler + # Invalidate cache when new handler is registered + self._invalidate_cache() + def _find_handler( self, action: str, scope_kwargs: dict[str, Any] ) -> Optional[Callable[[dict[str, Any]], Any]]: + """Find handler (delegates to cache mixin).""" + return self._find_handler_with_cache(action, scope_kwargs) + + def _match_handler( + self, action: str, scope_kwargs: dict[str, Any] + ) -> Optional[Callable[[dict[str, Any]], Any]]: + """Match handler based on action and scope dimensions.""" if action in self.global_handlers: return self.global_handlers[action] if not self.dimensions: @@ -148,8 +179,12 @@ def _find_handler( def register_global( self, action: str, handler: Callable[[dict[str, Any]], Any] ) -> None: + """Register a global handler for an action.""" self.global_handlers[action] = handler + # Invalidate cache when global handler is registered + self._invalidate_cache() + def global_handler( self, action: str ) -> Callable[[Callable[[dict[str, Any]], Any]], Callable[[dict[str, Any]], Any]]: diff --git a/action_dispatch/mixins/__init__.py b/action_dispatch/mixins/__init__.py new file mode 100644 index 0000000..4343775 --- /dev/null +++ b/action_dispatch/mixins/__init__.py @@ -0,0 +1,5 @@ +"""Mixins for ActionDispatcher.""" + +from .cache import CacheMixin + +__all__ = ["CacheMixin"] diff --git a/action_dispatch/mixins/cache.py b/action_dispatch/mixins/cache.py new file mode 100644 index 0000000..94c0257 --- /dev/null +++ b/action_dispatch/mixins/cache.py @@ -0,0 +1,129 @@ +"""Cache mixin for ActionDispatcher.""" + +from functools import lru_cache +from typing import Any, Callable, Optional + +# Default maximum size for LRU cache +DEFAULT_CACHE_MAXSIZE = 256 + + +class CacheMixin: + """ + Mixin class that adds LRU cache support to ActionDispatcher. + + This mixin provides optional caching for handler lookups, + which can improve performance when: + - You have many dimensions (10+) + - You have high-frequency repeated lookups + - The same action + scope combinations are called frequently + + Usage: + dispatcher = ActionDispatcher( + dimensions=["region", "platform", "version"], + enable_cache=True, + cache_maxsize=512 # or use default: DEFAULT_CACHE_MAXSIZE + ) + """ + + _cache_enabled: bool + _cache_maxsize: int + _cached_find_handler: Any # lru_cache wrapped function + + def _init_cache( + self, + enable_cache: bool = False, + cache_maxsize: int = DEFAULT_CACHE_MAXSIZE, + ) -> None: + """Initialize cache configuration.""" + self._cache_enabled = False + self._cache_maxsize = cache_maxsize + self._cached_find_handler = None + + if enable_cache: + self._setup_cache() + self._cache_enabled = True + + def _setup_cache(self) -> None: + """Setup LRU cache for handler lookup.""" + + @lru_cache(maxsize=self._cache_maxsize) + def cached_find( + action: str, scope_tuple: tuple[tuple[str, Any], ...] + ) -> Optional[Callable[[dict[str, Any]], Any]]: + scope_kwargs = dict(scope_tuple) + return self._match_handler(action, scope_kwargs) + + self._cached_find_handler = cached_find + + def _find_handler_with_cache( + self, action: str, scope_kwargs: dict[str, Any] + ) -> Optional[Callable[[dict[str, Any]], Any]]: + """Find handler with optional caching.""" + if self._cache_enabled and self._cached_find_handler is not None: + scope_tuple = tuple(sorted(scope_kwargs.items())) + result: Optional[Callable[[dict[str, Any]], Any]] = ( + self._cached_find_handler(action, scope_tuple) + ) + return result + + return self._match_handler(action, scope_kwargs) + + def _match_handler( + self, action: str, scope_kwargs: dict[str, Any] + ) -> Optional[Callable[[dict[str, Any]], Any]]: + """ + Match handler based on action and scope dimensions. + + This method should be overridden by the main class. + """ + raise NotImplementedError("Subclass must implement _match_handler") + + def _invalidate_cache(self) -> None: + """Invalidate cache when handlers change.""" + if self._cache_enabled: + self.clear_cache() + + def enable_cache(self, maxsize: Optional[int] = None) -> None: + """ + Enable LRU cache for handler lookup. + + Args: + maxsize: Maximum cache size (uses existing value if None). + """ + if maxsize is not None: + self._cache_maxsize = maxsize + self._setup_cache() + self._cache_enabled = True + + def disable_cache(self) -> None: + """Disable cache and clear cached data.""" + self._cache_enabled = False + self._cached_find_handler = None + + def clear_cache(self) -> None: + """Clear all cached handler lookups.""" + if self._cached_find_handler is not None: + self._cached_find_handler.cache_clear() + + def cache_info(self) -> Optional[dict[str, int]]: + """ + Get cache statistics. + + Returns: + Dict with hits, misses, maxsize, currsize, or None if disabled. + """ + if not self._cache_enabled or self._cached_find_handler is None: + return None + + info = self._cached_find_handler.cache_info() + return { + "hits": info.hits, + "misses": info.misses, + "maxsize": info.maxsize or 0, + "currsize": info.currsize, + } + + @property + def is_cache_enabled(self) -> bool: + """Check if cache is currently enabled.""" + return self._cache_enabled diff --git a/tests/test_cache.py b/tests/test_cache.py new file mode 100644 index 0000000..e9d8d9d --- /dev/null +++ b/tests/test_cache.py @@ -0,0 +1,196 @@ +"""Tests for cache functionality.""" + +import unittest + +from action_dispatch import ActionDispatcher + + +class TestCacheBasic(unittest.TestCase): + """Test basic cache functionality.""" + + def test_cache_disabled_by_default(self): + """Test that cache is disabled by default.""" + dispatcher = ActionDispatcher(dimensions=["platform"]) + self.assertFalse(dispatcher.is_cache_enabled) + + def test_enable_cache_on_init(self): + """Test enabling cache on initialization.""" + dispatcher = ActionDispatcher( + dimensions=["platform"], + enable_cache=True, + cache_maxsize=128, + ) + self.assertTrue(dispatcher.is_cache_enabled) + + def test_enable_cache_at_runtime(self): + """Test enabling cache at runtime.""" + dispatcher = ActionDispatcher(dimensions=["platform"]) + self.assertFalse(dispatcher.is_cache_enabled) + + dispatcher.enable_cache(maxsize=64) + self.assertTrue(dispatcher.is_cache_enabled) + + def test_disable_cache(self): + """Test disabling cache.""" + dispatcher = ActionDispatcher( + dimensions=["platform"], + enable_cache=True, + ) + self.assertTrue(dispatcher.is_cache_enabled) + + dispatcher.disable_cache() + self.assertFalse(dispatcher.is_cache_enabled) + + +class TestCacheInfo(unittest.TestCase): + """Test cache info functionality.""" + + def test_cache_info_when_disabled(self): + """Test that cache_info returns None when cache is disabled.""" + dispatcher = ActionDispatcher(dimensions=["platform"]) + self.assertIsNone(dispatcher.cache_info()) + + def test_cache_info_when_enabled(self): + """Test that cache_info returns stats when cache is enabled.""" + dispatcher = ActionDispatcher( + dimensions=["platform"], + enable_cache=True, + ) + + @dispatcher.handler("test_action", platform="mobile") + def handler(params): + return "result" + + # Make some lookups + dispatcher.get_handler("test_action", platform="mobile") + dispatcher.get_handler("test_action", platform="mobile") + + info = dispatcher.cache_info() + self.assertIsNotNone(info) + self.assertIn("hits", info) + self.assertIn("misses", info) + self.assertIn("maxsize", info) + self.assertIn("currsize", info) + + def test_cache_hits_and_misses(self): + """Test that cache tracks hits and misses correctly.""" + dispatcher = ActionDispatcher( + dimensions=["platform"], + enable_cache=True, + ) + + @dispatcher.handler("test_action", platform="mobile") + def handler(params): + return "result" + + # First lookup - miss + dispatcher.get_handler("test_action", platform="mobile") + info = dispatcher.cache_info() + self.assertEqual(info["misses"], 1) + self.assertEqual(info["hits"], 0) + + # Second lookup - hit + dispatcher.get_handler("test_action", platform="mobile") + info = dispatcher.cache_info() + self.assertEqual(info["misses"], 1) + self.assertEqual(info["hits"], 1) + + +class TestCacheClear(unittest.TestCase): + """Test cache clear functionality.""" + + def test_clear_cache(self): + """Test clearing the cache.""" + dispatcher = ActionDispatcher( + dimensions=["platform"], + enable_cache=True, + ) + + @dispatcher.handler("test_action", platform="mobile") + def handler(params): + return "result" + + # Make a lookup + dispatcher.get_handler("test_action", platform="mobile") + info = dispatcher.cache_info() + self.assertEqual(info["currsize"], 1) + + # Clear cache + dispatcher.clear_cache() + info = dispatcher.cache_info() + self.assertEqual(info["currsize"], 0) + + def test_cache_invalidated_on_handler_registration(self): + """Test that cache is invalidated when new handler is registered.""" + dispatcher = ActionDispatcher( + dimensions=["platform"], + enable_cache=True, + ) + + @dispatcher.handler("action1", platform="mobile") + def handler1(params): + return "result1" + + # Make a lookup to populate cache + dispatcher.get_handler("action1", platform="mobile") + info = dispatcher.cache_info() + self.assertEqual(info["currsize"], 1) + + # Register new handler - should clear cache + @dispatcher.handler("action2", platform="desktop") + def handler2(params): + return "result2" + + info = dispatcher.cache_info() + self.assertEqual(info["currsize"], 0) + + +class TestCacheWithDispatch(unittest.TestCase): + """Test cache with dispatch functionality.""" + + def test_dispatch_with_cache(self): + """Test that dispatch works correctly with cache enabled.""" + dispatcher = ActionDispatcher( + dimensions=["platform", "version"], + enable_cache=True, + ) + + @dispatcher.handler("get_data", platform="mobile", version="2.0") + def get_data_mobile_v2(params): + return "data_mobile_v2" + + class Context: + platform = "mobile" + version = "2.0" + + result = dispatcher.dispatch(Context(), "get_data") + self.assertEqual(result, "data_mobile_v2") + + # Second call should use cache + result = dispatcher.dispatch(Context(), "get_data") + self.assertEqual(result, "data_mobile_v2") + + info = dispatcher.cache_info() + self.assertGreater(info["hits"], 0) + + +class TestCacheWithoutDimensions(unittest.TestCase): + """Test cache with no dimensions.""" + + def test_cache_with_no_dimensions(self): + """Test that cache works with no dimensions.""" + dispatcher = ActionDispatcher(enable_cache=True) + + @dispatcher.handler("simple_action") + def handler(params): + return "simple_result" + + result = dispatcher.get_handler("simple_action") + self.assertIsNotNone(result) + + info = dispatcher.cache_info() + self.assertEqual(info["currsize"], 1) + + +if __name__ == "__main__": + unittest.main()