diff --git a/CHANGELOG.md b/CHANGELOG.md index 929ab39..0241aee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,15 +5,15 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased] - -### Planned -- Distributed tracing/observability -- Metrics export (Prometheus) -- Cache warming strategies -- Serialization plugins (msgpack, protobuf) -- Redis cluster support -- DynamoDB backend example +## 0.1.5 - 2025-12-15 + +### Added +- RedisCache now supports pluggable serializers with built-ins for `pickle` (default) and `json`, plus custom `dumps`/`loads` implementations. +- `HybridCache.from_redis` helper for a one-liner L1 (in-memory) + L2 (Redis) setup. +- `HybridCache` now supports `l2_ttl` parameter for independent L2 TTL control. Defaults to `l1_ttl * 2` if not specified. +- `__version__` attribute exposed in the main module for version checking. +- Comprehensive test coverage for BGCache lambda cache factory pattern and HybridCache l2_ttl behavior. +- Documentation example for using lambda cache factories with BGCache (lazy Redis connection initialization). ## [0.1.4] - 2025-12-12 diff --git a/GITHUB_ACTIONS.md b/GITHUB_ACTIONS.md deleted file mode 100644 index 7ac0b69..0000000 --- a/GITHUB_ACTIONS.md +++ /dev/null @@ -1,241 +0,0 @@ -# GitHub Actions Setup Guide - -## Overview - -Three automated workflows have been created for your project: - -1. **tests.yml** - Runs tests on every push and PR -2. **publish.yml** - Publishes to PyPI on version tags -3. **codeql.yml** - Security scanning with CodeQL - ---- - -## Workflow Details - -### 1. Tests Workflow (tests.yml) - -**Triggers:** -- Every push to `main` or `develop` branch -- Every pull request to `main` or `develop` - -**What it does:** -- Runs tests on 3 operating systems: Ubuntu, macOS, Windows -- Tests on Python 3.10, 3.11, 3.12 (9 total combinations) -- Verifies package structure with `verify.py` -- Runs unit tests with pytest -- Runs benchmarks (Ubuntu + Python 3.12 only) -- Generates code coverage report -- Uploads coverage to Codecov - -**Status:** Green check (✅) on PR means all tests passed! - -### 2. Publish Workflow (publish.yml) - -**Triggers:** -- When you push a version tag (e.g., `v0.1.0`, `v0.2.0`) - -**What it does:** -1. **Build Job:** - - Checks out code - - Verifies package with `verify.py` - - Runs all tests - - Builds wheel and source distribution - - Validates build with twine - -2. **Publish Job:** - - Publishes to PyPI (uses OIDC Trusted Publisher) - - Creates GitHub Release with built files - - Includes installation instructions in release notes - -**How to trigger:** -```bash -git tag v0.2.0 -git push origin v0.2.0 -``` - -### 3. CodeQL Workflow (codeql.yml) - -**Triggers:** -- Every push to `main` or `develop` -- Every PR -- Weekly (Sunday at midnight) - -**What it does:** -- Analyzes Python code for security vulnerabilities -- Reports findings to GitHub Security tab -- Runs automatically, no action needed - ---- - -## Setup Instructions - -### Step 1: Verify Files Exist - -The three workflow files have been created in `.github/workflows/`: -``` -.github/ -└── workflows/ - ├── tests.yml - ├── publish.yml - └── codeql.yml -``` - -### Step 2: Create CHANGELOG.md - -The CHANGELOG.md file has been created with release notes. - -### Step 3: Configure PyPI Publishing (Optional but Recommended) - -For automatic PyPI publishing, you have two options: - -**Option A: Trusted Publisher (Recommended)** -1. Go to PyPI: https://pypi.org/manage/account/publishing/ -2. Click "Add a new pending publisher" -3. Fill in: - - PyPI Project Name: `advanced-caching` - - GitHub Repository Owner: Your GitHub username - - Repository Name: `advanced_caching` (replace with your repo name) - - Workflow Name: `publish.yml` - - Environment Name: (leave empty) -4. Verify with GitHub -5. Done! No token needed - -**Option B: API Token (Backup method)** -1. Go to PyPI: https://pypi.org/manage/account/tokens/ -2. Click "Create token" -3. Name: "GitHub Actions" -4. Scope: "entire repository" -5. Copy the token -6. Go to GitHub repo → Settings → Secrets and variables → Actions -7. Click "New repository secret" -8. Name: `PYPI_API_TOKEN` -9. Value: Paste the token - ---- - -## Publishing a New Version - -### Step 1: Update Version - -Edit `pyproject.toml`: -```toml -[project] -version = "0.2.0" # Change from 0.1.0 -``` - -### Step 2: Update CHANGELOG - -Add entry to `CHANGELOG.md` under `## [Unreleased]`: - -```markdown -## [0.2.0] - 2025-12-11 - -### Added -- New feature 1 -- New feature 2 - -### Fixed -- Bug fix 1 -``` - -Then move it to the proper dated section. - -### Step 3: Commit Changes - -```bash -git add pyproject.toml CHANGELOG.md -git commit -m "Release v0.2.0: New features" -git push origin main -``` - -### Step 4: Create Release Tag - -```bash -git tag v0.2.0 -git push origin v0.2.0 -``` - -**That's it!** The workflow automatically: -- ✅ Runs all tests -- ✅ Builds the package -- ✅ Publishes to PyPI -- ✅ Creates GitHub Release -- ✅ Uploads build artifacts - -### Step 5: Monitor (Optional) - -Watch the workflow progress: -1. Go to GitHub repo → Actions tab -2. Click on the "Publish to PyPI" workflow -3. Watch jobs: Build → Publish -4. Once complete, check PyPI: https://pypi.org/project/advanced-caching/ - ---- - -## Complete Publish Example - -```bash -# 1. Update version -sed -i '' 's/version = "0.1.0"/version = "0.2.0"/' pyproject.toml - -# 2. Add CHANGELOG entry (manually edit CHANGELOG.md) - -# 3. Commit -git add pyproject.toml CHANGELOG.md -git commit -m "Release v0.2.0: New features and improvements" -git push origin main - -# 4. Tag and push (TRIGGERS WORKFLOW) -git tag v0.2.0 -git push origin v0.2.0 - -# 5. Done! Check GitHub Actions tab for progress -# Package will appear on PyPI in 2-5 minutes -``` - ---- - -## Troubleshooting - -| Issue | Solution | -|-------|----------| -| Tests fail in CI | Run `pytest tests/test_correctness.py -v` locally first | -| PyPI publish fails | Ensure Trusted Publisher is configured or API token is set | -| Release not on PyPI | Wait 2-5 minutes, PyPI caches updates | -| Tag not triggering | Ensure tag format is `v*` (e.g., `v0.1.0`) | -| Can't find workflow runs | Check GitHub repo → Actions tab | - ---- - -## Workflow Status Badges - -Add these to your README.md for status badges: - -```markdown -[![Tests](https://github.com/YOUR_USERNAME/advanced_caching/workflows/Tests/badge.svg)](https://github.com/YOUR_USERNAME/advanced_caching/actions/workflows/tests.yml) -[![Publish](https://github.com/YOUR_USERNAME/advanced_caching/workflows/Publish%20to%20PyPI/badge.svg)](https://github.com/YOUR_USERNAME/advanced_caching/actions/workflows/publish.yml) -``` - -Replace `YOUR_USERNAME` with your GitHub username. - ---- - -## Next Steps - -1. ✅ Workflows are set up -2. ✅ CHANGELOG.md created -3. ⏭️ Push to GitHub -4. ⏭️ Configure Trusted Publisher (optional) -5. ⏭️ Test with a release (tag v0.1.0 or v0.1.1) - ---- - -## Files Created - -- `.github/workflows/tests.yml` - Test automation -- `.github/workflows/publish.yml` - PyPI publishing -- `.github/workflows/codeql.yml` - Security scanning -- `CHANGELOG.md` - Release notes and version history - -All files are ready to use! - diff --git a/README.md b/README.md index 55fc4f0..a92c51b 100644 --- a/README.md +++ b/README.md @@ -4,683 +4,320 @@ [![Python 3.10+](https://img.shields.io/badge/python-3.10+-blue.svg)](https://www.python.org/downloads/) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) -**Production-ready caching library** with decorators for TTL, stale-while-revalidate (SWR), and background refresh. Type-safe, fast, and framework-agnostic. - -## Quick Links - -- [Installation](#installation) – Get started in 30 seconds -- [Quick Examples](#quick-start) – Copy-paste ready code -- [API Reference](#api-reference) – Full decorator & backend docs -- [Storage & Redis](#storage--redis) – Redis/Hybrid/custom storage examples -- [Custom Storage](#custom-storage) – Implement your own backend -- [Benchmarks](#benchmarks) – See the performance gains -- [Use Cases](#use-cases) – Real-world examples - -## Features - -| Feature | Details | -|---------|---------| -| **TTL Caching** | Simple time-based expiration with key patterns | -| **SWR Pattern** | Serve stale data instantly, refresh in background | -| **Background Loading** | Pre-load expensive data with periodic refresh | -| **Multiple Backends** | In-memory, Redis, custom storage, or hybrid | -| **Thread-Safe** | Reentrant locks, atomic operations, concurrent-safe | -| **Type-Safe** | Full type hints, IDE-friendly, zero runtime overhead | -| **Framework-Agnostic** | Works with FastAPI, Flask, Django, async, or sync | -| **No Required Dependencies** | Only APScheduler; Redis is optional | +**Production-ready caching library** for Python with TTL, stale-while-revalidate (SWR), and background refresh. +Type-safe, fast, thread-safe, async-friendly, and framework-agnostic. + +> Issues & feature requests: [new issue](https://github.com/agkloop/advanced_caching/issues/new) + +--- + +## Table of Contents +- [Installation](#installation) +- [Quick Start](#quick-start) +- [Key Templates](#key-templates) +- [Storage Backends](#storage-backends) + - [InMemCache](#inmemcache) + - [RedisCache & Serializers](#rediscache--serializers) + - [HybridCache (L1 + L2)](#hybridcache-l1--l2) + - [Custom Storage](#custom-storage) +- [API Reference](#api-reference) +- [Testing & Benchmarks](#testing--benchmarks) +- [Use Cases](#use-cases) +- [Comparison](#comparison) +- [Contributing](#contributing) +- [License](#license) + +--- ## Installation -### Quick Install ```bash -pip install advanced-caching -# or with uv (recommended) -uv pip install advanced-caching -# with Redis support -pip install "advanced-caching[redis]" -# with Redis support (uv) -uv pip install "advanced-caching[redis]" -``` +uv pip install advanced-caching # core +uv pip install "advanced-caching[redis]" # Redis support +# pip works too +```` + +--- ## Quick Start -### 1. TTL Cache – Time-Based Expiration ```python -from advanced_caching import TTLCache +from advanced_caching import TTLCache, SWRCache, BGCache -@TTLCache.cached("user:{}", ttl=300) # Cache 5 minutes +@TTLCache.cached("user:{}", ttl=300) def get_user(user_id: int) -> dict: return db.fetch(user_id) -user = get_user(42) # First: executes & caches -user = get_user(42) # Later: instant from cache ~0.001ms -``` - -### 2. SWR Cache – Serve Stale, Refresh in Background -```python -from advanced_caching import SWRCache - @SWRCache.cached("product:{}", ttl=60, stale_ttl=30) def get_product(product_id: int) -> dict: return api.fetch_product(product_id) -product = get_product(1) # Returns immediately (fresh or stale) -# Stale data served instantly, refresh happens in background -``` - -### 3. Background Cache – Pre-Loaded Data -```python -from advanced_caching import BGCache - +# Background refresh @BGCache.register_loader("inventory", interval_seconds=300) def load_inventory() -> list[dict]: return warehouse_api.get_all_items() -inventory = load_inventory() # Instant ~0.001ms (pre-loaded) -# Refreshes every 5 minutes automatically -``` - -### 4. Async Support -```python -# All decorators work with async functions +# Async works too @TTLCache.cached("user:{}", ttl=300) async def get_user_async(user_id: int) -> dict: - return await db.fetch_user(user_id) - -user = await get_user_async(42) + return await db.fetch(user_id) ``` -## Benchmarks -Full benchmarks available in `tests/benchmark.py`. - -Step-by-step benchmarking + profiling guide: `docs/benchmarking-and-profiling.md`. - -Storage & Redis usage is documented below. - -## API Reference - -### Key templates & custom keys - -All caching decorators share the same key concept: - -- `key: str` – String template or literal -- `key: Callable[..., str]` – Function that returns a string key - -Supported patterns: - -1. **Positional placeholder** – first positional argument: - - ```python - @TTLCache.cached("user:{}", ttl=60) - def get_user(user_id: int): - ... - - get_user(42) # key -> "user:42" - ``` - -2. **Named placeholder** – keyword arguments by name: - - ```python - @TTLCache.cached("user:{user_id}", ttl=60) - def get_user(*, user_id: int): - ... - - get_user(user_id=42) # key -> "user:42" - ``` - -3. **Named with extra kwargs** – only the named part is used for the key: - - ```python - @SWRCache.cached("i18n:{lang}", ttl=60, stale_ttl=30) - def load_i18n(lang: str, region: str | None = None): - ... - - load_i18n(lang="en", region="US") # key -> "i18n:en" - ``` - -4. **Default arguments + robust key lambda** – recommended for complex/default cases: - - ```python - @SWRCache.cached( - key=lambda *a, **k: f"i18n:all:{k.get('lang', a[0] if a else 'en')}", - ttl=60, - stale_ttl=30, - ) - def load_all(lang: str = "en") -> dict: - print(f"Loading i18n for {lang}") - return {"hello": f"Hello in {lang}"} - - load_all() # key -> "i18n:all:en" - load_all("en") # key -> "i18n:all:en" - load_all(lang="en") # key -> "i18n:all:en" - # Body runs once, subsequent calls are cached - ``` - --- -### TTLCache.cached(key, ttl, cache=None) -Simple time-based cache with configurable TTL. - -**Signature:** -```python -TTLCache.cached( - key: str | Callable[..., str], - ttl: int, - cache: CacheStorage | Callable[[], CacheStorage] | None = None, -) -> Callable -``` +## Key Templates -**Parameters:** -- `key` (str | callable): Cache key template or generator function -- `ttl` (int): Time-to-live in seconds -- `cache` (CacheStorage): Optional custom backend (defaults to InMemCache) +* `"user:{}"` → first positional argument +* `"user:{user_id}"` → named argument +* Custom: -**Examples:** - -Positional key: ```python -@TTLCache.cached("user:{}", ttl=300) -def get_user(user_id: int): - return db.fetch(user_id) - -get_user(42) # key -> "user:42" -``` - -Named key: -```python -@TTLCache.cached("user:{user_id}", ttl=300) -def get_user(*, user_id: int): - return db.fetch(user_id) - -get_user(user_id=42) # key -> "user:42" -``` - -Custom key function: -```python -@TTLCache.cached(key=lambda *a, **k: f"user:{k.get('user_id', a[0])}", ttl=300) -def get_user(user_id: int = 0): - return db.fetch(user_id) +key=lambda *a, **k: f"user:{k.get('user_id', a[0])}" ``` --- -### SWRCache.cached(key, ttl, stale_ttl=0, cache=None, enable_lock=True) -Serve stale data instantly while refreshing in background. - -**Signature:** -```python -SWRCache.cached( - key: str | Callable[..., str], - ttl: int, - stale_ttl: int = 0, - cache: CacheStorage | Callable[[], CacheStorage] | None = None, - enable_lock: bool = True, -) -> Callable -``` - -**Parameters:** -- `key` (str | callable): Cache key (same patterns as TTLCache) -- `ttl` (int): Fresh data TTL in seconds -- `stale_ttl` (int): Grace period to serve stale data while refreshing -- `cache` (CacheStorage): Optional custom backend -- `enable_lock` (bool): Prevent thundering herd (default: True) - -**Examples:** +## Storage Backends -Basic SWR with positional key: -```python -@SWRCache.cached("product:{}", ttl=60, stale_ttl=30) -def get_product(product_id: int): - return api.fetch_product(product_id) +### InMemCache -get_product(1) # key -> "product:1" -``` +Thread-safe in-memory cache with TTL. -Named key with kwargs: ```python -@SWRCache.cached("i18n:{lang}", ttl=60, stale_ttl=30) -def load_i18n(*, lang: str = "en") -> dict: - return {"hello": f"Hello in {lang}"} - -load_i18n(lang="en") # key -> "i18n:en" -``` +from advanced_caching import InMemCache -Default arg + key lambda (robust): -```python -@SWRCache.cached( - key=lambda *a, **k: f"i18n:all:{k.get('lang', a[0] if a else 'en')}", - ttl=60, - stale_ttl=30, -) -def load_all(lang: str = "en") -> dict: - return {"hello": f"Hello in {lang}"} +cache = InMemCache() +cache.set("key", "value", ttl=60) +cache.get("key") +cache.delete("key") +cache.exists("key") +cache.set_if_not_exists("key", "value", ttl=60) +cache.cleanup_expired() ``` --- -### BGCache.register_loader(key, interval_seconds, ttl=None, run_immediately=True, on_error=None, cache=None) -Pre-load expensive data with periodic refresh. +### RedisCache & Serializers -**Signature:** ```python -BGCache.register_loader( - key: str, - interval_seconds: int, - ttl: int | None = None, - run_immediately: bool = True, - on_error: Callable[[Exception], None] | None = None, - cache: CacheStorage | Callable[[], CacheStorage] | None = None, -) -> Callable -``` - -**Parameters:** -- `key` (str): Unique cache key (no formatting, fixed string) -- `interval_seconds` (int): Refresh interval in seconds -- `ttl` (int | None): Cache TTL (defaults to 2 × interval_seconds when None) -- `run_immediately` (bool): Load once at registration (default: True) -- `on_error` (callable): Error handler function `(Exception) -> None` -- `cache` (CacheStorage): Optional custom backend - -**Examples:** - -Sync loader: -```python -from advanced_caching import BGCache +import redis +from advanced_caching import RedisCache, JsonSerializer -@BGCache.register_loader(key="inventory", interval_seconds=300, ttl=900) -def load_inventory() -> list[dict]: - return warehouse_api.get_all_items() +client = redis.Redis(host="localhost", port=6379) -# Later -items = load_inventory() # instant access to cached data +cache = RedisCache(client, prefix="app:") +json_cache = RedisCache(client, prefix="app:json:", serializer="json") +custom_json = RedisCache(client, prefix="app:json2:", serializer=JsonSerializer()) ``` -Async loader: -```python -@BGCache.register_loader(key="products", interval_seconds=300, ttl=900) -async def load_products() -> list[dict]: - return await api.fetch_products() - -products = await load_products() # returns cached list -``` +#### Custom Serializer Example (msgpack) -With error handling: ```python -errors: list[Exception] = [] +import msgpack -def on_error(exc: Exception) -> None: - errors.append(exc) +class MsgpackSerializer: + handles_entries = False -@BGCache.register_loader( - key="unstable", - interval_seconds=60, - run_immediately=True, - on_error=on_error, -) -def maybe_fails() -> dict: - raise RuntimeError("boom") - -# errors list will contain the exception from background job -``` + @staticmethod + def dumps(obj): + return msgpack.packb(obj, use_bin_type=True) -Shutdown scheduler when done: -```python -BGCache.shutdown(wait=True) + @staticmethod + def loads(data): + return msgpack.unpackb(data, raw=False) ``` --- -### Storage Backends - -## Storage & Redis - -### Install (uv) - -```bash -uv pip install advanced-caching -uv pip install "advanced-caching[redis]" # for RedisCache / HybridCache -``` - -### How storage is chosen - -- If you don’t pass `cache=...`, each decorated function lazily creates its own `InMemCache` instance. -- You can pass either a cache instance (`cache=my_cache`) or a cache factory (`cache=lambda: my_cache`). +### HybridCache (L1 + L2) -### Share one storage instance +Two-level cache: -```python -from advanced_caching import InMemCache, TTLCache - -shared = InMemCache() - -@TTLCache.cached("user:{}", ttl=60, cache=shared) -def get_user(user_id: int) -> dict: - return {"id": user_id} - -@TTLCache.cached("org:{}", ttl=60, cache=shared) -def get_org(org_id: int) -> dict: - return {"id": org_id} -``` +* **L1**: In-memory +* **L2**: Redis -### Use RedisCache (distributed) - -`RedisCache` stores values in Redis using `pickle`. +#### Simple setup ```python import redis -from advanced_caching import RedisCache, TTLCache +from advanced_caching import HybridCache, TTLCache -client = redis.Redis(host="localhost", port=6379) -cache = RedisCache(client, prefix="app:") +client = redis.Redis() +hybrid = HybridCache.from_redis(client, prefix="app:", l1_ttl=60) -@TTLCache.cached("user:{}", ttl=300, cache=cache) -def get_user(user_id: int) -> dict: +@TTLCache.cached("user:{}", ttl=300, cache=hybrid) +def get_user(user_id: int): return {"id": user_id} ``` -### Use SWRCache with RedisCache (recommended) - -`SWRCache` uses `get_entry`/`set_entry` so it can store freshness metadata. +#### Manual wiring ```python -import redis -from advanced_caching import RedisCache, SWRCache - -client = redis.Redis(host="localhost", port=6379) -cache = RedisCache(client, prefix="products:") - -@SWRCache.cached("product:{}", ttl=60, stale_ttl=30, cache=cache) -def get_product(product_id: int) -> dict: - return {"id": product_id} -``` - -### Use HybridCache (L1 memory + L2 Redis) - -`HybridCache` is a two-level cache: -- **L1**: fast in-memory (`InMemCache`) -- **L2**: Redis-backed (`RedisCache`) - -Reads go to L1 first; on L1 miss it tries L2; on L2 hit it warms L1. - -```python -import redis -from advanced_caching import HybridCache, InMemCache, RedisCache, TTLCache +from advanced_caching import HybridCache, InMemCache, RedisCache -client = redis.Redis(host="localhost", port=6379) - -hybrid = HybridCache( - l1_cache=InMemCache(), - l2_cache=RedisCache(client, prefix="app:"), - l1_ttl=60, -) +l1 = InMemCache() +l2 = RedisCache(client, prefix="app:") +# l2_ttl defaults to l1_ttl * 2 if not specified +hybrid = HybridCache(l1_cache=l1, l2_cache=l2, l1_ttl=60) -@TTLCache.cached("user:{}", ttl=300, cache=hybrid) -def get_user(user_id: int) -> dict: - return {"id": user_id} +# Explicit l2_ttl for longer L2 persistence +hybrid_long_l2 = HybridCache(l1_cache=l1, l2_cache=l2, l1_ttl=60, l2_ttl=3600) ``` -Notes: -- `ttl` on the decorator controls how long values are considered valid. -- `l1_ttl` controls how long HybridCache keeps values in memory after an L2 hit. +**TTL behavior:** +- `l1_ttl`: How long data stays in fast L1 memory cache +- `l2_ttl`: How long data persists in L2 (Redis). Defaults to `l1_ttl * 2` +- When data expires from L1 but exists in L2, it's automatically repopulated to L1 -#### InMemCache() -Thread-safe in-memory cache with TTL. - -```python -from advanced_caching import InMemCache - -cache = InMemCache() -cache.set("key", value, ttl=60) -value = cache.get("key") # None if expired -cache.delete("key") -cache.exists("key") # bool -cache.set_if_not_exists("key", value, ttl) # bool -cache.cleanup_expired() # int count -``` +#### With BGCache using lambda factory -#### RedisCache(redis_client, prefix="") -Redis-backed distributed cache. +For lazy initialization (e.g., deferred Redis connection): ```python -import redis -from advanced_caching import RedisCache +from advanced_caching import BGCache, HybridCache, InMemCache, RedisCache -client = redis.Redis(host="localhost", port=6379) -cache = RedisCache(client, prefix="app:") -# Same methods as InMemCache -``` - -#### HybridCache(l1_cache=None, l2_cache=None, l1_ttl=60) -Two-level cache: L1 (fast in-memory) + L2 (persistent Redis). +def get_redis_cache(): + """Lazy Redis connection factory.""" + import redis + client = redis.Redis(host="localhost", port=6379) + return RedisCache(client, prefix="app:") -```python -cache = HybridCache( - l1_cache=None, # Defaults to InMemCache - l2_cache=RedisCache(client), - l1_ttl=60 +@BGCache.register_loader( + "config_map", + interval_seconds=3600, + run_immediately=True, + cache=lambda: HybridCache( + l1_cache=InMemCache(), + l2_cache=get_redis_cache(), + l1_ttl=3600, + l2_ttl=86400 # L2 persists longer than L1 + ) ) -# Hits from L1 (fast), misses fetch from L2 (distributed) -``` - -#### validate_cache_storage(cache) -> bool -Check if object implements CacheStorage protocol. - -```python -from advanced_caching import validate_cache_storage +def load_config_map() -> dict[str, dict]: + return {"db": {"host": "localhost"}, "cache": {"ttl": 300}} -assert validate_cache_storage(my_cache) # True if valid +# Access nested data +db_host = load_config_map().get("db", {}).get("host") ``` -#### CacheEntry -Access cache metadata (advanced use). - -```python -entry = cache.get_entry("key") -if entry and entry.is_fresh(): - print(f"Age: {entry.age():.1f}s") -``` +--- -## Custom Storage +### Custom Storage -Implement the `CacheStorage` protocol for custom backends (DynamoDB, file-based, encrypted storage, etc.). +Implement the `CacheStorage` protocol. -### File-based example +#### File-based example ```python -import json -import time +import json, time from pathlib import Path from advanced_caching import CacheEntry, CacheStorage, TTLCache, validate_cache_storage - class FileCache(CacheStorage): - def __init__(self, directory: str = "/tmp/cache"): - self.directory = Path(directory) - self.directory.mkdir(parents=True, exist_ok=True) + def __init__(self, directory="/tmp/cache"): + self.dir = Path(directory) + self.dir.mkdir(parents=True, exist_ok=True) - def _get_path(self, key: str) -> Path: - safe_key = key.replace("/", "_").replace(":", "_") - return self.directory / f"{safe_key}.json" + def _path(self, key: str) -> Path: + return self.dir / f"{key.replace(':','_')}.json" - def get_entry(self, key: str) -> CacheEntry | None: - path = self._get_path(key) - if not path.exists(): - return None - try: - with open(path) as f: - data = json.load(f) - return CacheEntry( - value=data["value"], - fresh_until=float(data["fresh_until"]), - created_at=float(data["created_at"]), - ) - except Exception: + def get_entry(self, key): + p = self._path(key) + if not p.exists(): return None + data = json.loads(p.read_text()) + return CacheEntry(**data) - def set_entry(self, key: str, entry: CacheEntry, ttl: int | None = None) -> None: - now = time.time() - if ttl is not None: - fresh_until = now + ttl if ttl > 0 else float("inf") - entry = CacheEntry(value=entry.value, fresh_until=fresh_until, created_at=now) - with open(self._get_path(key), "w") as f: - json.dump( - {"value": entry.value, "fresh_until": entry.fresh_until, "created_at": entry.created_at}, - f, - ) - - def get(self, key: str): - entry = self.get_entry(key) - if entry is None: - return None - if not entry.is_fresh(): - self.delete(key) - return None - return entry.value + def set_entry(self, key, entry, ttl=None): + self._path(key).write_text(json.dumps(entry.__dict__)) + + def get(self, key): + e = self.get_entry(key) + return e.value if e and e.is_fresh() else None - def set(self, key: str, value, ttl: int = 0) -> None: + def set(self, key, value, ttl=0): now = time.time() - fresh_until = now + ttl if ttl > 0 else float("inf") - self.set_entry(key, CacheEntry(value=value, fresh_until=fresh_until, created_at=now)) + self.set_entry(key, CacheEntry(value, now + ttl, now)) - def delete(self, key: str) -> None: - self._get_path(key).unlink(missing_ok=True) + def delete(self, key): + self._path(key).unlink(missing_ok=True) - def exists(self, key: str) -> bool: + def exists(self, key): return self.get(key) is not None - def set_if_not_exists(self, key: str, value, ttl: int) -> bool: + def set_if_not_exists(self, key, value, ttl): if self.exists(key): return False self.set(key, value, ttl) return True - -cache = FileCache("/tmp/app_cache") +cache = FileCache() assert validate_cache_storage(cache) - -@TTLCache.cached("user:{}", ttl=300, cache=cache) -def get_user(user_id: int): - return {"id": user_id} -``` - -### Best Practices - -1. **TTL Handling** – Implement expiration check in `get()` -2. **Thread Safety** – Use locks for multi-threaded access -3. **Error Handling** – Handle I/O errors gracefully -4. **Cleanup** – Remove expired entries periodically -5. **Validation** – Use `validate_cache_storage()` to verify - -## Testing - -### Run Tests -```bash -uv run pytest tests/test_correctness.py -v ``` -### Run Benchmarks -```bash -uv run python tests/benchmark.py -``` - - --- -## Use Cases - -### Web API Caching -```python -from fastapi import FastAPI -from advanced_caching import TTLCache - -app = FastAPI() - -@app.get("/users/{user_id}") -@TTLCache.cached("user:{}", ttl=300) -async def get_user(user_id: int): - return await db.fetch_user(user_id) -``` - -### Database Query Caching -```python -from advanced_caching import SWRCache - -@SWRCache.cached("posts:{}", ttl=60, stale_ttl=30) -def get_posts(user_id: int): - # Serves stale posts while fetching fresh ones - return db.query("SELECT * FROM posts WHERE user_id = ?", user_id) -``` +## API Reference -### Configuration/Settings -```python -from advanced_caching import BGCache +* `TTLCache.cached(key, ttl, cache=None)` +* `SWRCache.cached(key, ttl, stale_ttl=0, cache=None)` +* `BGCache.register_loader(key, interval_seconds, ttl=None, run_immediately=True)` +* Storages: -@BGCache.register_loader("config", interval_seconds=300, run_immediately=True) -def load_config(): - # Pre-loaded in background, always fresh - return settings.load_from_file() + * `InMemCache()` + * `RedisCache(redis_client, prefix="", serializer="pickle"|"json"|custom)` + * `HybridCache(l1_cache, l2_cache, l1_ttl=60, l2_ttl=None)` - `l2_ttl` defaults to `l1_ttl * 2` +* Utilities: -# In app startup -app.config = load_config() -``` + * `CacheEntry` + * `CacheStorage` + * `validate_cache_storage()` -### Distributed Caching -```python -import redis -from advanced_caching import HybridCache, RedisCache, TTLCache +--- -client = redis.Redis(host="redis-server") -cache = HybridCache( - l1_cache=None, - l2_cache=RedisCache(client), - l1_ttl=60 -) +## Testing & Benchmarks -@TTLCache.cached("data:{}", ttl=300, cache=cache) -def get_data(data_id): - return expensive_operation(data_id) +```bash +uv run pytest -q +uv run python tests/benchmark.py ``` - --- -## Comparison with Alternatives +## Use Cases -| Feature | advanced-caching | functools.lru_cache | cachetools | Redis | Memcached | -|---------|-----------------|-------------------|-----------|-------|-----------| -| TTL Support | ✅ | ❌ | ✅ | ✅ | ✅ | -| SWR Pattern | ✅ | ❌ | ❌ | Manual | Manual | -| Background Refresh | ✅ | ❌ | ❌ | Manual | Manual | -| Custom Backends | ✅ | ❌ | ❌ | N/A | N/A | -| Distributed | ✅ (Redis) | ❌ | ❌ | ✅ | ✅ | -| Async Support | ✅ | ❌ | ❌ | ✅ | ✅ | -| Type Safe | ✅ | ✅ | ✅ | ❌ | ❌ | -| Zero Dependencies | ❌ (APScheduler) | ✅ | ✅ | ❌ | ❌ | +* Web & API caching (FastAPI, Flask, Django) +* Database query caching +* SWR for upstream APIs +* Background refresh for configs & datasets +* Distributed caching with Redis +* Hybrid L1/L2 hot-path optimization --- -## Development +## Comparison + +| Feature | advanced-caching | lru_cache | cachetools | Redis | Memcached | +| ------------------ | ---------------- | --------- | ---------- | ------ | --------- | +| TTL | ✅ | ❌ | ✅ | ✅ | ✅ | +| SWR | ✅ | ❌ | ❌ | Manual | Manual | +| Background refresh | ✅ | ❌ | ❌ | Manual | Manual | +| Custom backends | ✅ | ❌ | ❌ | N/A | N/A | +| Distributed | ✅ | ❌ | ❌ | ✅ | ✅ | +| Async support | ✅ | ❌ | ❌ | ✅ | ✅ | +| Type hints | ✅ | ✅ | ✅ | ❌ | ❌ | -### Setup -```bash -git clone https://github.com/agkloop/advanced_caching.git -cd advanced_caching -uv sync -uv run pytest tests/ -v -``` --- ## Contributing -Contributions welcome! Please: -1. Fork the repository -2. Create a feature branch (`git checkout -b feature/my-feature`) -3. Add tests for new functionality -4. Ensure all tests pass (`uv run pytest`) -5. Submit a pull request +1. Fork the repo +2. Create a feature branch +3. Add tests +4. Run `uv run pytest` +5. Open a pull request --- ## License - -MIT License – See [LICENSE](LICENSE) for details. +MIT License – see [LICENSE](LICENSE). \ No newline at end of file diff --git a/src/advanced_caching/__init__.py b/src/advanced_caching/__init__.py index d4d7953..d43c7e3 100644 --- a/src/advanced_caching/__init__.py +++ b/src/advanced_caching/__init__.py @@ -4,6 +4,8 @@ Expose storage backends, decorators, and scheduler utilities under `advanced_caching`. """ +__version__ = "0.1.5" + from .storage import ( InMemCache, RedisCache, @@ -11,6 +13,8 @@ CacheEntry, CacheStorage, validate_cache_storage, + PickleSerializer, + JsonSerializer, ) from .decorators import ( TTLCache, @@ -21,12 +25,15 @@ ) __all__ = [ + "__version__", "InMemCache", "RedisCache", "HybridCache", "CacheEntry", "CacheStorage", "validate_cache_storage", + "PickleSerializer", + "JsonSerializer", "TTLCache", "SWRCache", "StaleWhileRevalidateCache", diff --git a/src/advanced_caching/storage.py b/src/advanced_caching/storage.py index 0f968a9..d20fa3f 100644 --- a/src/advanced_caching/storage.py +++ b/src/advanced_caching/storage.py @@ -7,6 +7,7 @@ from __future__ import annotations +import json import math import pickle import threading @@ -20,6 +21,50 @@ redis = None # type: ignore +class Serializer(Protocol): + """Simple serializer protocol used by RedisCache.""" + + def dumps(self, obj: Any) -> bytes: ... + + def loads(self, data: bytes) -> Any: ... + + +class PickleSerializer: + """Pickle serializer using highest protocol (fastest, flexible).""" + + __slots__ = () + handles_entries = True + + @staticmethod + def dumps(obj: Any) -> bytes: + return pickle.dumps(obj, protocol=pickle.HIGHEST_PROTOCOL) + + @staticmethod + def loads(data: bytes) -> Any: + return pickle.loads(data) + + +class JsonSerializer: + """JSON serializer for text-friendly payloads (wraps CacheEntry).""" + + __slots__ = () + handles_entries = False + + @staticmethod + def dumps(obj: Any) -> bytes: + return json.dumps(obj, separators=(",", ":")).encode("utf-8") + + @staticmethod + def loads(data: bytes) -> Any: + return json.loads(data.decode("utf-8")) + + +_BUILTIN_SERIALIZERS: dict[str, Serializer] = { + "pickle": PickleSerializer(), + "json": JsonSerializer(), +} + + # ============================================================================ # Cache Entry - Internal data structure # ============================================================================ @@ -239,18 +284,86 @@ class RedisCache: cache.set("user:123", {"name": "John"}, ttl=60) """ - def __init__(self, redis_client: Any, prefix: str = ""): + def __init__( + self, + redis_client: Any, + prefix: str = "", + serializer: str | Serializer | None = "pickle", + ): """ Initialize Redis cache. Args: redis_client: Redis client instance prefix: Key prefix for namespacing + serializer: Built-in name ("pickle" | "json" | "msgpack"), or + any object with ``dumps(obj)->bytes`` and ``loads(bytes)->Any``. """ if redis is None: raise ImportError("redis package required. Install: pip install redis") self.client = redis_client self.prefix = prefix + self._serializer, self._wrap_entries = self._resolve_serializer(serializer) + + @staticmethod + def _wrap_payload(obj: Any) -> Any: + if isinstance(obj, CacheEntry): + return { + "__ac_type": "entry", + "v": obj.value, + "f": obj.fresh_until, + "c": obj.created_at, + } + return {"__ac_type": "value", "v": obj} + + @staticmethod + def _unwrap_payload(obj: Any) -> Any: + if isinstance(obj, dict): + obj_type = obj.get("__ac_type") + if obj_type == "entry": + return CacheEntry( + value=obj.get("v"), + fresh_until=float(obj.get("f", 0.0)), + created_at=float(obj.get("c", 0.0)), + ) + if obj_type == "value": + return obj.get("v") + return obj + + def _serialize(self, obj: Any) -> bytes: + if self._wrap_entries: + return self._serializer.dumps(self._wrap_payload(obj)) + return self._serializer.dumps(obj) + + def _deserialize(self, data: bytes) -> Any: + obj = self._serializer.loads(data) + if self._wrap_entries: + return self._unwrap_payload(obj) + return obj + + def _resolve_serializer( + self, serializer: str | Serializer | None + ) -> tuple[Serializer, bool]: + if serializer is None: + serializer = "pickle" + + if isinstance(serializer, str): + name = serializer.lower() + if name not in _BUILTIN_SERIALIZERS: + raise ValueError( + "Unsupported serializer. Use 'pickle', 'json', or provide an object with dumps/loads." + ) + serializer_obj = _BUILTIN_SERIALIZERS[name] + return ( + serializer_obj, + not bool(getattr(serializer_obj, "handles_entries", False)), + ) + + if hasattr(serializer, "dumps") and hasattr(serializer, "loads"): + wrap = not bool(getattr(serializer, "handles_entries", False)) + return (serializer, wrap) + + raise TypeError("serializer must be a string or provide dumps/loads methods") def _make_key(self, key: str) -> str: """Add prefix to key.""" @@ -262,7 +375,7 @@ def get(self, key: str) -> Any | None: data = self.client.get(self._make_key(key)) if data is None: return None - value = pickle.loads(data) + value = self._deserialize(data) if isinstance(value, CacheEntry): return value.value if value.is_fresh() else None return value @@ -272,7 +385,7 @@ def get(self, key: str) -> Any | None: def set(self, key: str, value: Any, ttl: int = 0) -> None: """Set value with optional TTL in seconds.""" try: - data = pickle.dumps(value) + data = self._serialize(value) if ttl > 0: expires = max(1, int(math.ceil(ttl))) self.client.setex(self._make_key(key), expires, data) @@ -304,7 +417,7 @@ def get_entry(self, key: str) -> CacheEntry | None: data = self.client.get(self._make_key(key)) if data is None: return None - value = pickle.loads(data) + value = self._deserialize(data) if isinstance(value, CacheEntry): return value # Legacy plain values: wrap to allow SWR-style access @@ -316,7 +429,7 @@ def get_entry(self, key: str) -> CacheEntry | None: def set_entry(self, key: str, entry: CacheEntry, ttl: int | None = None) -> None: """Store CacheEntry, optionally with explicit TTL.""" try: - data = pickle.dumps(entry) + data = self._serialize(entry) expires = None if ttl is not None and ttl > 0: expires = max(1, int(math.ceil(ttl))) @@ -330,7 +443,7 @@ def set_entry(self, key: str, entry: CacheEntry, ttl: int | None = None) -> None def set_if_not_exists(self, key: str, value: Any, ttl: int) -> bool: """Atomic set if not exists.""" try: - data = pickle.dumps(value) + data = self._serialize(value) expires = None if ttl > 0: expires = max(1, int(math.ceil(ttl))) @@ -365,6 +478,7 @@ def __init__( l1_cache: CacheStorage | None = None, l2_cache: CacheStorage | None = None, l1_ttl: int = 60, + l2_ttl: int | None = None, ): """ Initialize hybrid cache. @@ -373,12 +487,14 @@ def __init__( l1_cache: L1 cache (memory), defaults to InMemCache l2_cache: L2 cache (distributed), required l1_ttl: TTL for L1 cache in seconds + l2_ttl: TTL for L2 cache in seconds, defaults to l1_ttl * 2 """ self.l1 = l1_cache if l1_cache is not None else InMemCache() if l2_cache is None: raise ValueError("l2_cache is required for HybridCache") self.l2 = l2_cache self.l1_ttl = l1_ttl + self.l2_ttl = l2_ttl if l2_ttl is not None else l1_ttl * 2 def get(self, key: str) -> Any | None: """Get value, checking L1 then L2.""" @@ -398,7 +514,8 @@ def get(self, key: str) -> Any | None: def set(self, key: str, value: Any, ttl: int = 0) -> None: """Set value in both L1 and L2.""" self.l1.set(key, value, min(ttl, self.l1_ttl) if ttl > 0 else self.l1_ttl) - self.l2.set(key, value, ttl) + l2_ttl = min(ttl, self.l2_ttl) if ttl > 0 else self.l2_ttl + self.l2.set(key, value, l2_ttl) def get_entry(self, key: str) -> CacheEntry | None: """Get raw entry preferring L1, falling back to L2 and repopulating L1.""" @@ -442,19 +559,22 @@ def exists(self, key: str) -> bool: def set_if_not_exists(self, key: str, value: Any, ttl: int) -> bool: """Atomic set if not exists (L2 only for consistency).""" - success = self.l2.set_if_not_exists(key, value, ttl) + l2_ttl = min(ttl, self.l2_ttl) if ttl > 0 else self.l2_ttl + success = self.l2.set_if_not_exists(key, value, l2_ttl) if success: self.l1.set(key, value, min(ttl, self.l1_ttl) if ttl > 0 else self.l1_ttl) return success def set_entry(self, key: str, entry: CacheEntry, ttl: int | None = None) -> None: - """Store raw entry in both layers, respecting L1 TTL.""" + """Store raw entry in both layers, respecting L1 and L2 TTL.""" ttl = ttl if ttl is not None else max(int(entry.fresh_until - time.time()), 0) l1_ttl = min(ttl, self.l1_ttl) if ttl > 0 else self.l1_ttl + l2_ttl = min(ttl, self.l2_ttl) if ttl > 0 else self.l2_ttl + self.l1.set_entry(key, entry, ttl=l1_ttl) if hasattr(self.l2, "set_entry"): - self.l2.set_entry(key, entry, ttl=ttl) # type: ignore[attr-defined] + self.l2.set_entry(key, entry, ttl=l2_ttl) # type: ignore[attr-defined] else: - self.l2.set(key, entry.value, ttl) + self.l2.set(key, entry.value, l2_ttl) diff --git a/tests/test.py b/tests/test.py deleted file mode 100644 index c070f6f..0000000 --- a/tests/test.py +++ /dev/null @@ -1,32 +0,0 @@ -from advanced_caching import BGCache -import inspect - - -is_not_testing = True - - -def get_loader_fn(): - caller_stack_trace = inspect.stack() - stack_fn_names_list = [frame.function for frame in caller_stack_trace] - - print("get_loader_fn called, setting up loader...") - - @BGCache.register_loader( - "all_filter_keys_catalog_loader", - interval_seconds=60 * 60 * 1 * is_not_testing, - run_immediately=True, - ) - def get_all_filter_keys_catalog(): - print("Loading all filter keys catalog...", stack_fn_names_list) - return "D" - - return get_all_filter_keys_catalog - - -def call_fn(): - return get_loader_fn() - - -if __name__ == "__main__": - for i in range(10): - print(i, call_fn()()) diff --git a/tests/test_correctness.py b/tests/test_correctness.py index de930d3..e1af30d 100644 --- a/tests/test_correctness.py +++ b/tests/test_correctness.py @@ -413,6 +413,69 @@ def call_loader(_: int): assert all(r == {"value": 1} for r in results) assert call_count["count"] == 1 + def test_lambda_cache_factory(self): + """Test BGCache with lambda returning HybridCache.""" + call_count = {"count": 0} + + @BGCache.register_loader( + "test_lambda_cache", + interval_seconds=3600, + run_immediately=True, + cache=lambda: HybridCache( + l1_cache=InMemCache(), l2_cache=InMemCache(), l1_ttl=60 + ), + ) + def get_test_data() -> dict[str, str]: + call_count["count"] += 1 + return {"key": "value", "count": str(call_count["count"])} + + # First call should hit the cache (run_immediately=True loaded it) + result1 = get_test_data() + assert result1 == {"key": "value", "count": "1"} + assert call_count["count"] == 1 + + # Second call should return cached value + result2 = get_test_data() + assert result2 == {"key": "value", "count": "1"} + assert call_count["count"] == 1 # No additional call + + # Verify cache object was created correctly + assert hasattr(get_test_data, "_cache") + assert get_test_data._cache is not None + assert isinstance(get_test_data._cache, HybridCache) + + def test_lambda_cache_nested_dict_access(self): + """Test nested dict access pattern with lambda cache factory.""" + + @BGCache.register_loader( + "nested_dict_map", + interval_seconds=3600, + run_immediately=True, + cache=lambda: HybridCache( + l1_cache=InMemCache(), l2_cache=InMemCache(), l1_ttl=3600 + ), + ) + def get_mapping() -> dict[str, dict]: + return { + "color": {"en": "Color", "fr": "Couleur"}, + "size": {"en": "Size", "fr": "Taille"}, + } + + # Test the exact pattern that could fail if lambda not instantiated + name = get_mapping().get("color", {}).get("en") + assert name == "Color" + + name = get_mapping().get("size", {}).get("fr") + assert name == "Taille" + + name = get_mapping().get("missing", {}).get("en") + assert name is None + + # Verify cache is properly instantiated (not a lambda) + cache_obj = get_mapping._cache + assert isinstance(cache_obj, HybridCache) + assert not callable(cache_obj) or hasattr(cache_obj, "get") + class TestCachePerformance: """Performance and speed tests.""" @@ -658,6 +721,127 @@ def f(*, x: int) -> int: assert calls["n"] == 1 +class TestHybridCache: + """Test HybridCache L1+L2 behavior with l2_ttl.""" + + def test_l2_ttl_defaults_to_l1_ttl_times_2(self): + """Test that l2_ttl defaults to l1_ttl * 2.""" + l1 = InMemCache() + l2 = InMemCache() + + cache = HybridCache(l1_cache=l1, l2_cache=l2, l1_ttl=60) + assert cache.l1_ttl == 60 + assert cache.l2_ttl == 120 + + def test_l2_ttl_explicit_value(self): + """Test that explicit l2_ttl is respected.""" + l1 = InMemCache() + l2 = InMemCache() + + cache = HybridCache(l1_cache=l1, l2_cache=l2, l1_ttl=60, l2_ttl=300) + assert cache.l1_ttl == 60 + assert cache.l2_ttl == 300 + + def test_set_respects_l2_ttl(self): + """Test that set() uses l2_ttl for L2 cache.""" + l1 = InMemCache() + l2 = InMemCache() + + # Set l1_ttl=1, l2_ttl=10 + cache = HybridCache(l1_cache=l1, l2_cache=l2, l1_ttl=1, l2_ttl=10) + + cache.set("key1", "value1", ttl=100) + + # Both should have the value immediately + assert cache.get("key1") == "value1" + assert l1.get("key1") == "value1" + assert l2.get("key1") == "value1" + + # Wait for L1 to expire (l1_ttl=1) + time.sleep(1.2) + + # L1 should be expired, but L2 should still have it + assert l1.get("key1") is None + assert l2.get("key1") == "value1" + + # HybridCache should fetch from L2 and repopulate L1 + assert cache.get("key1") == "value1" + assert l1.get("key1") == "value1" # L1 repopulated + + def test_set_entry_respects_l2_ttl(self): + """Test that set_entry() uses l2_ttl for L2 cache.""" + from advanced_caching.storage import CacheEntry + + l1 = InMemCache() + l2 = InMemCache() + + cache = HybridCache(l1_cache=l1, l2_cache=l2, l1_ttl=1, l2_ttl=10) + + now = time.time() + entry = CacheEntry(value="test_value", fresh_until=now + 100, created_at=now) + + cache.set_entry("key2", entry, ttl=100) + + # Both should have the entry (using get() which checks freshness) + assert cache.get("key2") == "test_value" + assert l1.get("key2") == "test_value" + assert l2.get("key2") == "test_value" + + # Wait for L1 to expire (l1_ttl=1) + time.sleep(1.2) + + # L1 expired (get() returns None for expired), L2 should still have it + assert l1.get("key2") is None + assert l2.get("key2") == "test_value" + + # HybridCache should fetch from L2 + assert cache.get("key2") == "test_value" + + def test_set_if_not_exists_respects_l2_ttl(self): + """Test that set_if_not_exists() uses l2_ttl for L2 cache.""" + l1 = InMemCache() + l2 = InMemCache() + + cache = HybridCache(l1_cache=l1, l2_cache=l2, l1_ttl=1, l2_ttl=10) + + # First set should succeed + assert cache.set_if_not_exists("key3", "value3", ttl=100) is True + assert cache.get("key3") == "value3" + + # Second set should fail (key exists) + assert cache.set_if_not_exists("key3", "value3_new", ttl=100) is False + assert cache.get("key3") == "value3" + + # Wait for L1 to expire + time.sleep(1.2) + + # L2 should still have it, so set_if_not_exists should fail + assert cache.set_if_not_exists("key3", "value3_new", ttl=100) is False + + # Value should still be original from L2 + assert cache.get("key3") == "value3" + + def test_l2_ttl_with_zero_ttl_in_set(self): + """Test that l2_ttl is used when ttl=0 is passed to set().""" + l1 = InMemCache() + l2 = InMemCache() + + cache = HybridCache(l1_cache=l1, l2_cache=l2, l1_ttl=2, l2_ttl=5) + + # Set with ttl=0 should use l1_ttl and l2_ttl defaults + cache.set("key4", "value4", ttl=0) + + assert cache.get("key4") == "value4" + + # Wait for L1 to expire + time.sleep(2.2) + + # L1 expired, but L2 should still have it (l2_ttl=5) + assert l1.get("key4") is None + assert l2.get("key4") == "value4" + assert cache.get("key4") == "value4" + + class TestNoCachingWhenZero: """Ensure ttl/interval_seconds == 0 disables caching/background behavior.""" diff --git a/tests/test_integration_redis.py b/tests/test_integration_redis.py index 0307a32..903e2bf 100644 --- a/tests/test_integration_redis.py +++ b/tests/test_integration_redis.py @@ -3,8 +3,10 @@ Uses testcontainers-python to spin up a real Redis instance for testing. """ +import pickle import pytest import time +from typing import Any try: import redis @@ -22,6 +24,7 @@ RedisCache, HybridCache, InMemCache, + JsonSerializer, ) @@ -126,6 +129,55 @@ def test_redis_cache_multiple_types(self, redis_client): cache.set("list", data_list, ttl=60) assert cache.get("list") == data_list + def test_redis_cache_json_serializer(self, redis_client): + """Ensure JSON serializer roundtrips values and entries.""" + cache = RedisCache(redis_client, prefix="json:", serializer=JsonSerializer()) + + payload = {"a": 1, "b": [1, 2, 3]} + cache.set("payload", payload, ttl=60) + assert cache.get("payload") == payload + + entry = CacheEntry( + value={"ok": True}, + fresh_until=time.time() + 5, + created_at=time.time(), + ) + cache.set_entry("entry", entry, ttl=5) + loaded = cache.get_entry("entry") + assert isinstance(loaded, CacheEntry) + assert loaded.value == entry.value + + def test_redis_cache_custom_serializer_handles_entries(self, redis_client): + """Custom serializer can opt-out of wrapping CacheEntry.""" + + class PickleSerializer: + handles_entries = True + + @staticmethod + def dumps(obj: Any) -> bytes: + return pickle.dumps(obj, protocol=pickle.HIGHEST_PROTOCOL) + + @staticmethod + def loads(data: bytes) -> Any: + return pickle.loads(data) + + cache = RedisCache( + redis_client, prefix="custom:", serializer=PickleSerializer() + ) + + cache.set("k", {"v": 1}, ttl=30) + assert cache.get("k") == {"v": 1} + + entry = CacheEntry( + value={"v": 2}, + fresh_until=time.time() + 5, + created_at=time.time(), + ) + cache.set_entry("entry", entry, ttl=5) + loaded = cache.get_entry("entry") + assert isinstance(loaded, CacheEntry) + assert loaded.value == entry.value + class TestTTLCacheWithRedis: """Test TTLCache decorator with Redis backend.""" @@ -350,6 +402,273 @@ def get_user(user_id: int): assert result2 == {"id": 1} assert calls["n"] == 1 + def test_hybridcache_l2_ttl_defaults_to_double_l1(self, redis_client): + """Test l2_ttl defaults to l1_ttl * 2.""" + l2 = RedisCache(redis_client, prefix="hybrid_l2:") + cache = HybridCache(l1_cache=InMemCache(), l2_cache=l2, l1_ttl=30) + + assert cache.l1_ttl == 30 + assert cache.l2_ttl == 60 + + def test_hybridcache_l2_ttl_explicit_value(self, redis_client): + """Test explicit l2_ttl is respected.""" + l2 = RedisCache(redis_client, prefix="hybrid_l2:") + cache = HybridCache(l1_cache=InMemCache(), l2_cache=l2, l1_ttl=10, l2_ttl=100) + + assert cache.l1_ttl == 10 + assert cache.l2_ttl == 100 + + def test_hybridcache_l2_ttl_persistence(self, redis_client): + """Test that l2_ttl allows L2 to persist longer than L1.""" + l1 = InMemCache() + l2 = RedisCache(redis_client, prefix="hybrid_persist:") + cache = HybridCache(l1_cache=l1, l2_cache=l2, l1_ttl=1, l2_ttl=10) + + cache.set("key1", "value1", ttl=100) + + # Both L1 and L2 should have it initially + assert l1.get("key1") == "value1" + assert l2.get("key1") == "value1" + + # Wait for L1 to expire + time.sleep(1.2) + + # L1 expired, L2 should still have it + assert l1.get("key1") is None + assert l2.get("key1") == "value1" + + # HybridCache should fetch from L2 and repopulate L1 + assert cache.get("key1") == "value1" + assert l1.get("key1") == "value1" + + def test_hybridcache_l2_ttl_with_set_entry(self, redis_client): + """Test set_entry respects l2_ttl.""" + l1 = InMemCache() + l2 = RedisCache(redis_client, prefix="hybrid_entry:") + cache = HybridCache(l1_cache=l1, l2_cache=l2, l1_ttl=1, l2_ttl=10) + + now = time.time() + entry = CacheEntry(value="test_value", fresh_until=now + 100, created_at=now) + + cache.set_entry("key2", entry, ttl=100) + + # Both should have it + assert cache.get("key2") == "test_value" + assert l1.get("key2") == "test_value" + assert l2.get("key2") == "test_value" + + # Wait for L1 to expire + time.sleep(1.2) + + # L1 expired, L2 still has it + assert l1.get("key2") is None + assert l2.get("key2") == "test_value" + + def test_hybridcache_l2_ttl_with_set_if_not_exists(self, redis_client): + """Test set_if_not_exists respects l2_ttl.""" + l1 = InMemCache() + l2 = RedisCache(redis_client, prefix="hybrid_atomic:") + cache = HybridCache(l1_cache=l1, l2_cache=l2, l1_ttl=1, l2_ttl=10) + + # First set succeeds + assert cache.set_if_not_exists("key3", "value3", ttl=100) is True + + # Wait for L1 to expire + time.sleep(1.2) + + # L2 should still have it, so second set fails + assert cache.set_if_not_exists("key3", "new_value", ttl=100) is False + + # Value should be from L2 + assert cache.get("key3") == "value3" + + def test_hybridcache_l2_ttl_shorter_than_requested(self, redis_client): + """Test that l2_ttl caps the TTL when set() is called with larger TTL.""" + l1 = InMemCache() + l2 = RedisCache(redis_client, prefix="hybrid_cap:") + cache = HybridCache(l1_cache=l1, l2_cache=l2, l1_ttl=2, l2_ttl=3) + + # Set with large TTL, should be capped by l2_ttl + cache.set("key4", "value4", ttl=1000) + + # Check that Redis has the key with capped TTL + redis_ttl = redis_client.ttl("hybrid_cap:key4") + # TTL should be approximately l2_ttl (3 seconds), allow some margin + assert 2 <= redis_ttl <= 4 + + def test_hybridcache_with_bgcache_and_l2_ttl(self, redis_client): + """Test BGCache with HybridCache using l2_ttl.""" + l2 = RedisCache(redis_client, prefix="hybrid_bg:") + cache = HybridCache(l1_cache=InMemCache(), l2_cache=l2, l1_ttl=10, l2_ttl=60) + + calls = {"n": 0} + + @BGCache.register_loader( + key="config_with_l2", + interval_seconds=30, + run_immediately=True, + cache=cache, + ) + def load_config(): + calls["n"] += 1 + return {"setting": "value", "count": calls["n"]} + + time.sleep(0.1) + + result = load_config() + assert result["count"] == 1 + assert calls["n"] == 1 + + # Verify it's cached + result2 = load_config() + assert result2["count"] == 1 + assert calls["n"] == 1 + + +class TestHybridCacheEdgeCases: + """Edge case tests for HybridCache with Redis.""" + + def test_hybridcache_zero_l1_ttl(self, redis_client): + """Test HybridCache with zero L1 TTL (infinite TTL in L1).""" + l1 = InMemCache() + l2 = RedisCache(redis_client, prefix="edge_zero:") + cache = HybridCache(l1_cache=l1, l2_cache=l2, l1_ttl=0, l2_ttl=10) + + cache.set("key", "value", ttl=10) + + # With l1_ttl=0, L1 stores with infinite TTL + assert l1.get("key") == "value" + # L2 should have it with l2_ttl cap + assert l2.get("key") == "value" + # HybridCache should return it + assert cache.get("key") == "value" + + # Wait for L2 to expire + time.sleep(10.2) + + # L1 still has it (infinite), L2 expired + assert l1.get("key") == "value" + assert l2.get("key") is None + + def test_hybridcache_large_values(self, redis_client): + """Test HybridCache with large values.""" + l2 = RedisCache(redis_client, prefix="edge_large:") + cache = HybridCache(l1_cache=InMemCache(), l2_cache=l2, l1_ttl=60) + + # Large nested structure + large_value = { + f"key_{i}": {"data": [j for j in range(100)]} for i in range(100) + } + + cache.set("large", large_value, ttl=60) + result = cache.get("large") + assert result == large_value + + def test_hybridcache_special_characters_in_keys(self, redis_client): + """Test HybridCache with special characters in keys.""" + l2 = RedisCache(redis_client, prefix="edge_special:") + cache = HybridCache(l1_cache=InMemCache(), l2_cache=l2, l1_ttl=60) + + special_keys = [ + "user:123", + "email:test@example.com", + "path:/api/v1/users", + "query:name=test&id=1", + ] + + for key in special_keys: + cache.set(key, f"value_for_{key}", ttl=60) + assert cache.get(key) == f"value_for_{key}" + + def test_hybridcache_concurrent_access(self, redis_client): + """Test HybridCache under concurrent access.""" + import concurrent.futures + + l2 = RedisCache(redis_client, prefix="edge_concurrent:") + cache = HybridCache(l1_cache=InMemCache(), l2_cache=l2, l1_ttl=60) + + def write_and_read(i): + key = f"concurrent_{i}" + cache.set(key, f"value_{i}", ttl=60) + result = cache.get(key) + return result == f"value_{i}" + + with concurrent.futures.ThreadPoolExecutor(max_workers=10) as executor: + results = list(executor.map(write_and_read, range(100))) + + assert all(results) + + def test_hybridcache_delete_propagates_to_both_layers(self, redis_client): + """Test that delete removes from both L1 and L2.""" + l1 = InMemCache() + l2 = RedisCache(redis_client, prefix="edge_delete:") + cache = HybridCache(l1_cache=l1, l2_cache=l2, l1_ttl=60) + + cache.set("delete_me", "value", ttl=60) + + # Verify both have it + assert l1.get("delete_me") == "value" + assert l2.get("delete_me") == "value" + + # Delete + cache.delete("delete_me") + + # Verify both don't have it + assert l1.get("delete_me") is None + assert l2.get("delete_me") is None + assert cache.get("delete_me") is None + + def test_hybridcache_none_values(self, redis_client): + """Test HybridCache correctly handles None values.""" + l2 = RedisCache(redis_client, prefix="edge_none:") + cache = HybridCache(l1_cache=InMemCache(), l2_cache=l2, l1_ttl=60) + + # Set explicit None value + cache.set("none_key", None, ttl=60) + result = cache.get("none_key") + assert result is None + + # But key should exist + assert cache.exists("none_key") + + def test_hybridcache_json_serializer_with_l2_ttl(self, redis_client): + """Test HybridCache with JSON serializer and l2_ttl.""" + l1 = InMemCache() + l2 = RedisCache(redis_client, prefix="edge_json:", serializer=JsonSerializer()) + cache = HybridCache(l1_cache=l1, l2_cache=l2, l1_ttl=1, l2_ttl=10) + + data = {"string": "test", "number": 42, "list": [1, 2, 3]} + cache.set("json_key", data, ttl=100) + + assert cache.get("json_key") == data + + # Wait for L1 to expire + time.sleep(1.2) + + # Should still get from L2 + assert cache.get("json_key") == data + + def test_hybridcache_very_short_l2_ttl(self, redis_client): + """Test HybridCache with very short l2_ttl caps L2 expiration.""" + l1 = InMemCache() + l2 = RedisCache(redis_client, prefix="edge_short:") + # L1 has longer TTL (60s) but L2 expires quickly (1s) + cache = HybridCache(l1_cache=l1, l2_cache=l2, l1_ttl=60, l2_ttl=1) + + cache.set("short_ttl", "value", ttl=100) + + # Initially both should have it + assert cache.get("short_ttl") == "value" + + # Wait for L2 to expire (l2_ttl=1 caps the L2 TTL) + time.sleep(1.2) + + # L1 should still have it (l1_ttl=60), but L2 expired + assert l1.get("short_ttl") == "value" + assert l2.get("short_ttl") is None + # HybridCache should return from L1 + assert cache.get("short_ttl") == "value" + class TestRedisPerformance: """Performance tests with Redis backend."""