From 08f25e88a9734d12c1c9c94d9bf299e76e24fdda Mon Sep 17 00:00:00 2001 From: Ray Walker Date: Wed, 5 Aug 2026 08:16:25 +1000 Subject: [PATCH 1/2] refactor!: delete dead L1 namespace-index/bulk-invalidation machinery (LAB-1433) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The L1 namespace-index / bulk-invalidation surface (CacheEntry.namespace, L1Cache's config param + _namespace_index, put()'s namespace= kwarg, invalidate_by_key/invalidate_by_namespace/invalidate_all, and L1CacheConfig.namespace_index) was inert end-to-end: the production L1Cache is constructed without config, the decorator wrapper never passes namespace= to put(), and there were zero src/ callers of the three invalidate_by_* methods. Same trust-bug family as LAB-388/LAB-520 — delete rather than wire, per that precedent. Docs (README preset matrix, configuration.md, l1-invalidation.md) corrected to stop claiming the feature. The live per-key invalidation path (L1Cache.invalidate(), used by invalidate_cache()) is untouched. BREAKING CHANGE: L1CacheConfig.namespace_index is removed. The flag was read by nothing and toggled no behavior, but it shipped in v0.17.1 and docs/configuration.md documented a copy-pasteable L1CacheConfig(..., namespace_index=True) example — L1CacheConfig is a frozen dataclass, so constructors still passing it now raise TypeError instead of silently lying. L1Cache.invalidate_by_key(), .invalidate_by_namespace() and .invalidate_all() are removed with it; per-key L1Cache.invalidate() is unaffected. Same removal shape as L1CacheConfig.invalidation_enabled in v0.16.0 (LAB-520). --- .secrets.baseline | 4 +- README.md | 1 - docs/configuration.md | 19 +- docs/features/l1-invalidation.md | 22 +- src/cachekit/config/decorator.py | 6 - src/cachekit/config/nested.py | 2 - src/cachekit/l1_cache.py | 85 +-- .../test_l1_invalidation_benchmarks.py | 520 ------------------ tests/unit/test_l1_invalidation.py | 242 -------- 9 files changed, 21 insertions(+), 880 deletions(-) delete mode 100644 tests/performance/test_l1_invalidation_benchmarks.py delete mode 100644 tests/unit/test_l1_invalidation.py diff --git a/.secrets.baseline b/.secrets.baseline index 7580ed5..fc84744 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -231,7 +231,7 @@ "filename": "src/cachekit/config/decorator.py", "hashed_secret": "1a9a9d37d8305b0cd8353468065cf844259e1b1f", "is_verified": false, - "line_number": 572 + "line_number": 567 } ], "src/cachekit/serializers/interop_serializer.py": [ @@ -887,5 +887,5 @@ } ] }, - "generated_at": "2026-07-29T00:15:05Z" + "generated_at": "2026-08-04T22:16:14Z" } diff --git a/README.md b/README.md index f9b04bb..23fa6a3 100644 --- a/README.md +++ b/README.md @@ -166,7 +166,6 @@ def get_user_profile(user_id: int): | Encryption | - | - | - | - | ✅ Required | | L1 SWR (L1-only mode) | - | ✅ | - | ✅ | ✅ | | L1 Invalidation | - | - | - | ✅ | ✅ | -| L1 Namespace Index | - | - | - | ✅ | ✅ | | Prometheus Metrics | - | - | - | ✅ | ✅ | | Tracing | - | ✅ | - | ✅ | ✅ | | Structured Logging | - | ✅ | - | ✅ | ✅ | diff --git a/docs/configuration.md b/docs/configuration.md index 3b16c20..294c17e 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -245,7 +245,6 @@ from cachekit.config import L1CacheConfig max_size_mb=100, swr_enabled=True, swr_threshold_ratio=0.5, # Refresh at 50% of TTL - namespace_index=True, ), backend=None ) @@ -261,12 +260,10 @@ def my_function(): | `max_size_mb` | int | `100` | Maximum L1 cache size in MB | | `swr_enabled` | bool | `True` | Enable stale-while-revalidate (SWR) — [L1-only mode](#l1-only-mode-backendnone) only | | `swr_threshold_ratio` | float | `0.5` | Refresh at X% of TTL, in `(0.0, 1.0]` — L1-only mode only | -| `namespace_index` | bool | `True` | Enable fast namespace-based invalidation | **L1 Cache Concepts:** - **Freshness**: When to serve stale data + trigger background refresh (SWR, [L1-only mode](#l1-only-mode-backendnone) only — with a backend configured these fields have no effect) - **Expiry**: Hard deadline when entry is deleted from cache -- **Namespace**: Logical grouping for bulk invalidation (see [L1 Invalidation Guide](features/l1-invalidation.md)) ### L1-Only Mode (`backend=None`) @@ -343,14 +340,14 @@ def secure_function(): **Feature Matrix by Intent:** -| Intent | SWR | Invalidation | Namespace Index | Max Size | Notes | -|--------|-----|--------------|-----------------|----------|-------| -| `minimal()` | ❌ | ❌ | ❌ | 100 MB | Speed-first, no integrity check | -| `test()` | ❌ | ❌ | ❌ | 100 MB | Deterministic, no monitoring | -| `dev()` | L1-only¹ | ❌ | ❌ | 100 MB | Verbose logs, no Prometheus | -| `production()` | L1-only¹ | ✓ | ✓ | 100 MB | Full observability | -| `secure()` | L1-only¹ | ✓ | ✓ | 100 MB | AES-256-GCM encryption required | -| `io()` | ✓ | ✓ | ✓ | 100 MB | Managed SaaS backend (closed beta — [request access](https://cachekit.io)); past-TTL [SWR](#stale-while-revalidate-stale_ttl) default-on (`stale_ttl = ttl`) | +| Intent | SWR | Invalidation | Max Size | Notes | +|--------|-----|--------------|----------|-------| +| `minimal()` | ❌ | ❌ | 100 MB | Speed-first, no integrity check | +| `test()` | ❌ | ❌ | 100 MB | Deterministic, no monitoring | +| `dev()` | L1-only¹ | ❌ | 100 MB | Verbose logs, no Prometheus | +| `production()` | L1-only¹ | ✓ | 100 MB | Full observability | +| `secure()` | L1-only¹ | ✓ | 100 MB | AES-256-GCM encryption required | +| `io()` | ✓ | ✓ | 100 MB | Managed SaaS backend (closed beta — [request access](https://cachekit.io)); past-TTL [SWR](#stale-while-revalidate-stale_ttl) default-on (`stale_ttl = ttl`) | ¹ Within-TTL refresh-ahead SWR runs **only in L1-only mode** (`backend=None`), where the SDK re-runs your function in the background past `ttl * swr_threshold_ratio`. With a backend configured, these presets have no SWR — `swr_enabled` has no effect outside L1-only mode (Redis exposes no read-side freshness signal). The only backed SWR is `@cache.io`'s past-TTL [`stale_ttl`](#stale-while-revalidate-stale_ttl) mode. diff --git a/docs/features/l1-invalidation.md b/docs/features/l1-invalidation.md index 011a714..9bdc407 100644 --- a/docs/features/l1-invalidation.md +++ b/docs/features/l1-invalidation.md @@ -221,8 +221,6 @@ config = L1CacheConfig( # SWR Settings swr_enabled=True, # Enable SWR (default: True) swr_threshold_ratio=0.5, # Refresh at X% of TTL (default: 0.5 = 50%) - - namespace_index=True, # Enable O(1) namespace lookups (default: True) ) ``` @@ -232,7 +230,6 @@ config = L1CacheConfig( | `max_size_mb` | int | `100` | Maximum memory usage in MB | | `swr_enabled` | bool | `True` | Enable stale-while-revalidate (L1-only mode, requires a `ttl`) | | `swr_threshold_ratio` | float | `0.5` | Refresh at X% of TTL, in `(0.0, 1.0]` | -| `namespace_index` | bool | `True` | Enable fast namespace lookups | ### Intent Presets @@ -241,7 +238,7 @@ CacheKit includes preconfigured presets for common use cases: ```python notest from cachekit import cache -# Development: SWR only, no namespace indexing +# Development: SWR only @cache.dev() def dev_function(): pass @@ -269,14 +266,14 @@ def test_function(): **Feature Behavior by Preset:** -| Preset | SWR | Namespace Index | -|--------|-----|-----------------| -| `minimal()` | ❌ | ❌ | -| `test()` | ❌ | ❌ | -| `dev()` | L1-only¹ | ❌ | -| `production()` | L1-only¹ | ✓ | -| `secure()` | L1-only¹ | ✓ | -| `io()` | ✓² | ✓ | +| Preset | SWR | +|--------|-----| +| `minimal()` | ❌ | +| `test()` | ❌ | +| `dev()` | L1-only¹ | +| `production()` | L1-only¹ | +| `secure()` | L1-only¹ | +| `io()` | ✓² | ¹ Within-TTL SWR runs only in L1-only mode (`backend=None`) — with a backend configured, `swr_enabled` has no effect (see the callout at the top of this page). ² `@cache.io` ships past-TTL SWR via [`stale_ttl`](../configuration.md#stale-while-revalidate-stale_ttl) (default-on), using the CachekitIO backend's freshness signal — a different mechanism from the L1-only within-TTL refresh described here. @@ -340,7 +337,6 @@ SWR keeps hits at L1 speed, even when serving slightly stale data. In backed mod ### Memory Impact -- Namespace indexing: ~100 bytes per unique namespace - L1-only SWR bookkeeping: a per-entry version counter plus ~8 bytes per key with a refresh in flight For typical workloads (1000s of keys), overhead is <1MB. diff --git a/src/cachekit/config/decorator.py b/src/cachekit/config/decorator.py index e086368..dd3cee5 100644 --- a/src/cachekit/config/decorator.py +++ b/src/cachekit/config/decorator.py @@ -337,7 +337,6 @@ def minimal(cls, **kwargs: Any) -> DecoratorConfig: l1=L1CacheConfig( enabled=True, swr_enabled=False, - namespace_index=False, ), circuit_breaker=CircuitBreakerConfig(enabled=False), backpressure=BackpressureConfig(enabled=True), @@ -377,7 +376,6 @@ def production(cls, **kwargs: Any) -> DecoratorConfig: l1=L1CacheConfig( enabled=True, swr_enabled=True, - namespace_index=True, ), circuit_breaker=CircuitBreakerConfig(enabled=True), backpressure=BackpressureConfig(enabled=True), @@ -442,7 +440,6 @@ def secure(cls, master_key: str, tenant_extractor: Callable[..., str] | None = N l1=L1CacheConfig( enabled=True, # L1 stores encrypted bytes. Enabled: ~50ns hits vs 2-7ms Redis swr_enabled=True, - namespace_index=True, ), encryption=EncryptionConfig( enabled=True, @@ -490,7 +487,6 @@ def dev(cls, **kwargs: Any) -> DecoratorConfig: l1=L1CacheConfig( enabled=True, swr_enabled=True, - namespace_index=False, ), circuit_breaker=CircuitBreakerConfig(enabled=True), backpressure=BackpressureConfig(enabled=True), @@ -530,7 +526,6 @@ def test(cls, **kwargs: Any) -> DecoratorConfig: l1=L1CacheConfig( enabled=True, swr_enabled=False, - namespace_index=False, ), circuit_breaker=CircuitBreakerConfig(enabled=False), backpressure=BackpressureConfig(enabled=False), @@ -603,7 +598,6 @@ def io(cls, **kwargs: Any) -> DecoratorConfig: l1=L1CacheConfig( enabled=True, swr_enabled=True, - namespace_index=True, ), circuit_breaker=CircuitBreakerConfig(enabled=True), backpressure=BackpressureConfig(enabled=True), diff --git a/src/cachekit/config/nested.py b/src/cachekit/config/nested.py index bd01f79..ad5789e 100644 --- a/src/cachekit/config/nested.py +++ b/src/cachekit/config/nested.py @@ -30,7 +30,6 @@ class L1CacheConfig: task and sync functions via a daemon thread. swr_threshold_ratio: Fraction of TTL after which a hit triggers a background refresh, in (0.0, 1.0] (default: 0.5) - namespace_index: Enable fast namespace-based invalidation (default: True) Examples: Create with defaults: @@ -58,7 +57,6 @@ class L1CacheConfig: max_size_mb: int | None = None swr_enabled: bool = True swr_threshold_ratio: float = 0.5 - namespace_index: bool = True def validate(self) -> None: """Validate L1 cache configuration. diff --git a/src/cachekit/l1_cache.py b/src/cachekit/l1_cache.py index b8163ab..845d4ec 100644 --- a/src/cachekit/l1_cache.py +++ b/src/cachekit/l1_cache.py @@ -8,7 +8,7 @@ import math import threading import time -from collections import OrderedDict, defaultdict +from collections import OrderedDict from dataclasses import dataclass from typing import Any, Optional @@ -27,7 +27,6 @@ class CacheEntry: value: bytes expires_at: float size_bytes: int - namespace: Optional[str] = None def is_expired(self) -> bool: """Check if entry has expired.""" @@ -60,7 +59,6 @@ def __init__( max_memory_mb: int = 100, ttl_buffer_seconds: float = 1.0, namespace: str = "default", - config: Optional[Any] = None, ): """Initialize L1 cache. @@ -68,7 +66,6 @@ def __init__( max_memory_mb: Maximum memory usage in MB (default 100MB) ttl_buffer_seconds: Buffer time before Redis TTL expiry (default 1s) namespace: Cache namespace for isolation - config: Optional L1CacheConfig for invalidation features (namespace index) """ self.max_memory_bytes = max_memory_mb * 1024 * 1024 self.ttl_buffer_seconds = ttl_buffer_seconds @@ -88,10 +85,6 @@ def __init__( self._evictions = 0 self._expired_evictions = 0 - # Namespace index for O(1) invalidation (optional) - if config and config.namespace_index: - self._namespace_index: dict[str, set[str]] = defaultdict(set) - logger.info( "L1Cache initialized: namespace=%s, max_memory=%dMB, ttl_buffer=%.1fs", namespace, @@ -151,7 +144,6 @@ def put( value: bytes, redis_ttl: Optional[float] = None, expires_at: Optional[float] = None, - namespace: Optional[str] = None, ) -> None: """Store value in L1 cache with TTL. @@ -160,7 +152,6 @@ def put( value: Bytes to cache (encrypted or plaintext msgpack, not deserialized object) redis_ttl: TTL in seconds from Redis (used to calculate expiry) expires_at: Absolute expiry timestamp (overrides redis_ttl) - namespace: Optional namespace for invalidation support Raises: TypeError: if `value` is not exactly `bytes`. L1 stores raw bytes only; a memoryview @@ -219,22 +210,15 @@ def put( if key in self._cache: old_entry = self._cache[key] self._current_memory_bytes -= old_entry.size_bytes - # Remove old namespace index entry - if hasattr(self, "_namespace_index") and old_entry.namespace: - self._namespace_index[old_entry.namespace].discard(key) # Evict entries if needed to make room self._evict_for_space(size) # Store new entry - entry = CacheEntry(value=value, expires_at=expiry, size_bytes=size, namespace=namespace) + entry = CacheEntry(value=value, expires_at=expiry, size_bytes=size) self._cache[key] = entry self._current_memory_bytes += size - # Update namespace index - if hasattr(self, "_namespace_index") and namespace: - self._namespace_index[namespace].add(key) - # Move to end (most recently used) self._cache.move_to_end(key) @@ -248,10 +232,6 @@ def _remove_entry(self, key: str) -> None: entry = self._cache.pop(key) self._current_memory_bytes -= entry.size_bytes - # Update namespace index - if hasattr(self, "_namespace_index") and entry.namespace: - self._namespace_index[entry.namespace].discard(key) - def _evict_for_space(self, needed_bytes: int) -> None: """Evict LRU entries to make space for new entry. @@ -289,67 +269,6 @@ def invalidate(self, key: str) -> None: with self._lock: self._remove_entry(key) - def invalidate_by_key(self, key: str) -> bool: - """Invalidate a specific cache key. - - Args: - key: Cache key to invalidate - - Returns: - True if key was found and removed, False otherwise - """ - with self._lock: - if key in self._cache: - self._remove_entry(key) - return True - return False - - def invalidate_by_namespace(self, namespace: str) -> int: - """Invalidate all entries in a namespace. - - Args: - namespace: Namespace to invalidate - - Returns: - Number of entries invalidated - """ - with self._lock: - # Use namespace index if available (O(1) lookup + O(k) delete) - if hasattr(self, "_namespace_index"): - keys_to_remove = list(self._namespace_index.get(namespace, set())) - for key in keys_to_remove: - self._remove_entry(key) - # Clear namespace index entry - if namespace in self._namespace_index: - del self._namespace_index[namespace] - return len(keys_to_remove) - - # Fallback: scan all entries (O(n)) - keys_to_remove = [key for key, entry in self._cache.items() if entry.namespace == namespace] - for key in keys_to_remove: - self._remove_entry(key) - return len(keys_to_remove) - - def invalidate_all(self) -> int: - """Invalidate all entries in cache. - - Returns: - Number of entries invalidated - """ - with self._lock: - count = len(self._cache) - - # Clear all data structures - self._cache.clear() - self._current_memory_bytes = 0 - - # Clear namespace index - if hasattr(self, "_namespace_index"): - self._namespace_index.clear() - - logger.info("L1Cache invalidated all %d entries for namespace: %s", count, self.namespace) - return count - def clear(self) -> None: """Clear all entries from L1 cache.""" with self._lock: diff --git a/tests/performance/test_l1_invalidation_benchmarks.py b/tests/performance/test_l1_invalidation_benchmarks.py deleted file mode 100644 index 2dd6e11..0000000 --- a/tests/performance/test_l1_invalidation_benchmarks.py +++ /dev/null @@ -1,520 +0,0 @@ -"""L1 cache invalidation performance benchmarks. - -Validates that L1 cache invalidation maintains sub-microsecond latency -and doesn't regress the hot path. - -Benchmarks: -- invalidate_by_key(): Single key invalidation -- invalidate_by_namespace(): Namespace invalidation (with/without index) -- invalidate_all(): Global invalidation - -SLA Targets: -- get() hit: <1500ns p95 -- invalidate_by_key(): <1000ns p95 (single dict delete) -- invalidate_by_namespace() with index: <2000ns p95 for 100 keys -- invalidate_all(): <5000ns p95 for 1000 entries -""" - -from __future__ import annotations - -import statistics -import threading -import time - -import pytest - -from cachekit.config.nested import L1CacheConfig -from cachekit.l1_cache import L1Cache - -# ============================================================================= -# Invalidation Benchmarks -# ============================================================================= - - -@pytest.mark.performance -def test_invalidate_by_key_latency() -> None: - """Test single-key invalidation latency. - - invalidate_by_key() does: - - Dict lookup - - Entry removal - - Namespace index update - - Should be <1000ns p95. - """ - config = L1CacheConfig( - enabled=True, - max_size_mb=100, - namespace_index=True, - ) - cache = L1Cache(max_memory_mb=100, config=config) - iterations = 50_000 - - print(f"\nBenchmarking invalidate_by_key() ({iterations:,} iterations)...") - - # Warm up - for i in range(1000): - cache.put(f"warmup:{i}", b"x" * 512, redis_ttl=3600, namespace="warmup") - cache.invalidate_by_key(f"warmup:{i}") - - # Measure invalidation latency - latencies = [] - for i in range(iterations): - key = f"inv:key:{i}" - cache.put(key, b"x" * 512, redis_ttl=3600, namespace="inv") - - start = time.perf_counter_ns() - removed = cache.invalidate_by_key(key) - end = time.perf_counter_ns() - - assert removed, "Key should be invalidated" - latencies.append(end - start) - - p50 = statistics.median(latencies) - p95 = statistics.quantiles(latencies, n=20)[18] - p99 = statistics.quantiles(latencies, n=100)[98] - mean = statistics.mean(latencies) - - results = { - "iterations": iterations, - "mean_ns": mean, - "p50_ns": p50, - "p95_ns": p95, - "p99_ns": p99, - } - - print(f"\n{'=' * 60}") - print("invalidate_by_key() Latency") - print(f"{'=' * 60}") - print(f"Iterations: {results['iterations']:>10,}") - print(f"Mean: {results['mean_ns']:>10.2f} ns") - print(f"P50: {results['p50_ns']:>10.2f} ns") - print(f"P95: {results['p95_ns']:>10.2f} ns") - print(f"P99: {results['p99_ns']:>10.2f} ns") - - target_ns = 1000 - if results["p95_ns"] >= target_ns: - raise AssertionError(f"invalidate_by_key() latency {results['p95_ns']:.0f}ns (p95) exceeds {target_ns}ns target") - - print(f"\n✅ invalidate_by_key() validated: {results['p95_ns']:.0f}ns < {target_ns}ns target") - - -@pytest.mark.performance -def test_invalidate_by_namespace_with_index_latency() -> None: - """Test namespace invalidation with O(1) index lookup. - - With namespace_index=True: - - O(1) index lookup to find keys - - O(k) removal where k = keys in namespace - - For 100 keys in namespace, should be <5000ns p95. - """ - config = L1CacheConfig( - enabled=True, - max_size_mb=100, - namespace_index=True, # Index enabled - ) - cache = L1Cache(max_memory_mb=100, config=config) - iterations = 1_000 - keys_per_namespace = 100 - - print(f"\nBenchmarking invalidate_by_namespace() WITH index ({iterations:,} iterations)...") - print(f"Keys per namespace: {keys_per_namespace}") - - latencies = [] - for i in range(iterations): - namespace = f"ns:{i}" - - # Populate namespace with keys - for j in range(keys_per_namespace): - cache.put(f"{namespace}:key:{j}", b"x" * 256, redis_ttl=3600, namespace=namespace) - - start = time.perf_counter_ns() - count = cache.invalidate_by_namespace(namespace) - end = time.perf_counter_ns() - - assert count == keys_per_namespace, f"Expected {keys_per_namespace}, got {count}" - latencies.append(end - start) - - p50 = statistics.median(latencies) - p95 = statistics.quantiles(latencies, n=20)[18] - mean = statistics.mean(latencies) - - # Calculate per-key latency - per_key_p95 = p95 / keys_per_namespace - - results = { - "iterations": iterations, - "keys_per_namespace": keys_per_namespace, - "mean_ns": mean, - "p50_ns": p50, - "p95_ns": p95, - "per_key_p95_ns": per_key_p95, - } - - print(f"\n{'=' * 60}") - print("invalidate_by_namespace() WITH Index") - print(f"{'=' * 60}") - print(f"Iterations: {results['iterations']:>10,}") - print(f"Keys/NS: {results['keys_per_namespace']:>10,}") - print(f"Mean: {results['mean_ns']:>10.2f} ns") - print(f"P50: {results['p50_ns']:>10.2f} ns") - print(f"P95: {results['p95_ns']:>10.2f} ns") - print(f"Per-key p95: {results['per_key_p95_ns']:>10.2f} ns/key") - - # 100 keys should invalidate in <100μs (~1μs per key is acceptable) - target_ns = 100_000 # 100μs for 100 keys - if results["p95_ns"] >= target_ns: - raise AssertionError(f"invalidate_by_namespace() latency {results['p95_ns']:.0f}ns (p95) exceeds {target_ns}ns target") - - print(f"\n✅ Namespace invalidation validated: {results['p95_ns']:.0f}ns < {target_ns}ns target") - print(f" ({results['per_key_p95_ns']:.0f}ns per key - O(1) index lookup working)") - - -@pytest.mark.performance -def test_invalidate_by_namespace_without_index_latency() -> None: - """Test namespace invalidation WITHOUT index (O(n) scan fallback). - - With namespace_index=False: - - O(n) scan through all entries - - Slower but works for @cache.minimal - - For 100 target keys in 1000 total entries, should be <100μs p95. - """ - config = L1CacheConfig( - enabled=True, - max_size_mb=100, - namespace_index=False, # Index DISABLED - ) - cache = L1Cache(max_memory_mb=100, config=config) - iterations = 100 - total_entries = 1000 - target_namespace_keys = 100 - - print(f"\nBenchmarking invalidate_by_namespace() WITHOUT index ({iterations:,} iterations)...") - print(f"Total entries: {total_entries}, Target namespace keys: {target_namespace_keys}") - - latencies = [] - for i in range(iterations): - # Populate cache with mixed namespaces - target_ns = f"target:{i}" - for j in range(target_namespace_keys): - cache.put(f"{target_ns}:key:{j}", b"x" * 256, redis_ttl=3600, namespace=target_ns) - - # Add other namespace entries - for j in range(total_entries - target_namespace_keys): - cache.put(f"other:{i}:key:{j}", b"x" * 256, redis_ttl=3600, namespace=f"other:{i % 10}") - - start = time.perf_counter_ns() - count = cache.invalidate_by_namespace(target_ns) - end = time.perf_counter_ns() - - assert count == target_namespace_keys, f"Expected {target_namespace_keys}, got {count}" - latencies.append(end - start) - - # Clear for next iteration - cache.clear() - - p50 = statistics.median(latencies) - p95 = statistics.quantiles(latencies, n=20)[18] - mean = statistics.mean(latencies) - - results = { - "iterations": iterations, - "total_entries": total_entries, - "target_keys": target_namespace_keys, - "mean_ns": mean, - "p50_ns": p50, - "p95_ns": p95, - } - - print(f"\n{'=' * 60}") - print("invalidate_by_namespace() WITHOUT Index (O(n) scan)") - print(f"{'=' * 60}") - print(f"Iterations: {results['iterations']:>10,}") - print(f"Total entries: {results['total_entries']:>10,}") - print(f"Target keys: {results['target_keys']:>10,}") - print(f"Mean: {results['mean_ns']:>10.2f} ns") - print(f"P50: {results['p50_ns']:>10.2f} ns") - print(f"P95: {results['p95_ns']:>10.2f} ns") - - # O(n) scan is slower - allow 500μs for 1000 entries - target_ns = 500_000 # 500μs - if results["p95_ns"] >= target_ns: - raise AssertionError( - f"invalidate_by_namespace() (no index) latency {results['p95_ns']:.0f}ns exceeds {target_ns}ns target" - ) - - print(f"\n✅ Fallback scan validated: {results['p95_ns']:.0f}ns < {target_ns}ns target") - print(" (O(n) scan is slower but acceptable for @cache.minimal)") - - -@pytest.mark.performance -def test_invalidate_all_latency() -> None: - """Test global invalidation latency. - - invalidate_all() does: - - Clear all entries - - Clear namespace index - - For 1000 entries, should be <50μs p95. - """ - config = L1CacheConfig( - enabled=True, - max_size_mb=100, - namespace_index=True, - ) - cache = L1Cache(max_memory_mb=100, config=config) - iterations = 500 - entries = 1000 - - print(f"\nBenchmarking invalidate_all() ({iterations:,} iterations)...") - print(f"Entries per iteration: {entries}") - - latencies = [] - for i in range(iterations): - # Populate cache - for j in range(entries): - cache.put(f"all:{i}:key:{j}", b"x" * 256, redis_ttl=3600, namespace=f"ns:{j % 10}") - - start = time.perf_counter_ns() - count = cache.invalidate_all() - end = time.perf_counter_ns() - - assert count == entries, f"Expected {entries}, got {count}" - latencies.append(end - start) - - p50 = statistics.median(latencies) - p95 = statistics.quantiles(latencies, n=20)[18] - mean = statistics.mean(latencies) - - # Calculate per-entry latency - per_entry_p95 = p95 / entries - - results = { - "iterations": iterations, - "entries": entries, - "mean_ns": mean, - "p50_ns": p50, - "p95_ns": p95, - "per_entry_p95_ns": per_entry_p95, - } - - print(f"\n{'=' * 60}") - print("invalidate_all() Latency") - print(f"{'=' * 60}") - print(f"Iterations: {results['iterations']:>10,}") - print(f"Entries: {results['entries']:>10,}") - print(f"Mean: {results['mean_ns']:>10.2f} ns") - print(f"P50: {results['p50_ns']:>10.2f} ns") - print(f"P95: {results['p95_ns']:>10.2f} ns") - print(f"Per-entry p95: {results['per_entry_p95_ns']:>10.2f} ns/entry") - - # 1000 entries should clear in <500μs (~500ns/entry is reasonable) - target_ns = 500_000 # 500μs - if results["p95_ns"] >= target_ns: - raise AssertionError(f"invalidate_all() latency {results['p95_ns']:.0f}ns (p95) exceeds {target_ns}ns target") - - print(f"\n✅ invalidate_all() validated: {results['p95_ns']:.0f}ns < {target_ns}ns target") - print(f" ({results['per_entry_p95_ns']:.0f}ns per entry)") - - -# ============================================================================= -# Concurrent Invalidation Benchmarks -# ============================================================================= - - -@pytest.mark.performance -def test_concurrent_invalidation_throughput() -> None: - """Test invalidation throughput under thread contention. - - Multiple threads invalidating different keys simultaneously. - Validates RLock doesn't cause severe contention. - """ - config = L1CacheConfig( - enabled=True, - max_size_mb=100, - namespace_index=True, - ) - cache = L1Cache(max_memory_mb=100, config=config) - num_threads = 8 - ops_per_thread = 5_000 - - print(f"\nBenchmarking concurrent invalidation ({num_threads} threads)...") - print(f"Operations per thread: {ops_per_thread:,}") - - # Pre-populate cache - total_keys = num_threads * ops_per_thread - for i in range(total_keys): - cache.put(f"concurrent:key:{i}", b"x" * 256, redis_ttl=3600, namespace=f"ns:{i % 10}") - - results_by_thread: dict[int, list[int]] = {i: [] for i in range(num_threads)} - lock = threading.Lock() - - def worker(thread_id: int) -> None: - latencies = [] - base_key = thread_id * ops_per_thread - - for i in range(ops_per_thread): - key = f"concurrent:key:{base_key + i}" - - start = time.perf_counter_ns() - cache.invalidate_by_key(key) - end = time.perf_counter_ns() - - latencies.append(end - start) - - with lock: - results_by_thread[thread_id] = latencies - - # Launch threads - start_time = time.perf_counter() - threads = [] - for i in range(num_threads): - t = threading.Thread(target=worker, args=(i,)) - threads.append(t) - t.start() - - for t in threads: - t.join() - end_time = time.perf_counter() - - # Combine results - all_latencies = [] - for latencies in results_by_thread.values(): - all_latencies.extend(latencies) - - total_ops = len(all_latencies) - elapsed_seconds = end_time - start_time - throughput = total_ops / elapsed_seconds - - p50 = statistics.median(all_latencies) - p95 = statistics.quantiles(all_latencies, n=20)[18] - p99 = statistics.quantiles(all_latencies, n=100)[98] - mean = statistics.mean(all_latencies) - - results = { - "threads": num_threads, - "total_ops": total_ops, - "elapsed_seconds": elapsed_seconds, - "throughput_ops_sec": throughput, - "mean_ns": mean, - "p50_ns": p50, - "p95_ns": p95, - "p99_ns": p99, - } - - print(f"\n{'=' * 60}") - print(f"Concurrent Invalidation ({num_threads} threads)") - print(f"{'=' * 60}") - print(f"Total ops: {results['total_ops']:>10,}") - print(f"Elapsed: {results['elapsed_seconds']:>10.2f} s") - print(f"Throughput: {results['throughput_ops_sec']:>10,.0f} ops/sec") - print(f"Mean: {results['mean_ns']:>10.2f} ns") - print(f"P50: {results['p50_ns']:>10.2f} ns") - print(f"P95: {results['p95_ns']:>10.2f} ns") - print(f"P99: {results['p99_ns']:>10.2f} ns") - - # Under contention, p95 should stay reasonable - target_ns = 5000 # Allow 5x single-threaded overhead - if results["p95_ns"] >= target_ns: - raise AssertionError( - f"Concurrent invalidation latency {results['p95_ns']:.0f}ns (p95) exceeds {target_ns}ns target\n" - f"RLock contention is too severe" - ) - - # Throughput should be at least 100k ops/sec - min_throughput = 100_000 - if results["throughput_ops_sec"] < min_throughput: - raise AssertionError(f"Concurrent throughput {results['throughput_ops_sec']:.0f} ops/sec below {min_throughput} target") - - print("\n✅ Concurrent invalidation validated:") - print(f" Latency: {results['p95_ns']:.0f}ns < {target_ns}ns target") - print(f" Throughput: {results['throughput_ops_sec']:,.0f} ops/sec > {min_throughput:,} target") - - -# ============================================================================= -# SLA Summary Test -# ============================================================================= - - -@pytest.mark.performance -def test_l1_invalidation_total_sla() -> None: - """Validate overall L1 invalidation feature SLA. - - This test validates the complete invalidation story: - - Hot-path get() stays fast alongside invalidation bookkeeping - - Single-key invalidation is fast - - Namespace invalidation scales with index - - Global invalidation is reasonable - """ - print(f"\n{'=' * 60}") - print("L1 Invalidation Total SLA Validation") - print(f"{'=' * 60}") - - config = L1CacheConfig( - enabled=True, - max_size_mb=100, - namespace_index=True, - ) - cache = L1Cache(max_memory_mb=100, config=config) - - # Populate cache - for i in range(1000): - cache.put(f"sla:key:{i}", b"x" * 512, redis_ttl=3600, namespace=f"ns:{i % 10}") - - results = {} - - # Test 1: Hot-path hit latency - get_latencies = [] - for i in range(10_000): - start = time.perf_counter_ns() - found, _ = cache.get(f"sla:key:{i % 1000}") - end = time.perf_counter_ns() - # A miss returns early and is *faster* than a hit, so an unasserted miss - # would let this benchmark pass the SLA while measuring the wrong path. - if not found: - raise AssertionError(f"SLA setup error: expected an L1 hit for sla:key:{i % 1000}") - get_latencies.append(end - start) - results["get_hit_p95"] = statistics.quantiles(get_latencies, n=20)[18] - - # Test 2: Single-key invalidation - inv_latencies = [] - for i in range(1000): - cache.put(f"sla:inv:{i}", b"x" * 512, redis_ttl=3600, namespace="inv") - start = time.perf_counter_ns() - cache.invalidate_by_key(f"sla:inv:{i}") - end = time.perf_counter_ns() - inv_latencies.append(end - start) - results["invalidate_key_p95"] = statistics.quantiles(inv_latencies, n=20)[18] - - # Test 3: Namespace invalidation (100 keys) - ns_latencies = [] - for i in range(100): - for j in range(100): - cache.put(f"sla:ns:{i}:key:{j}", b"x" * 256, redis_ttl=3600, namespace=f"sla:ns:{i}") - start = time.perf_counter_ns() - cache.invalidate_by_namespace(f"sla:ns:{i}") - end = time.perf_counter_ns() - ns_latencies.append(end - start) - results["invalidate_ns_100_p95"] = statistics.quantiles(ns_latencies, n=20)[18] - - print("\nSLA Results:") - print(f" get() hit: {results['get_hit_p95']:>8.0f} ns (target: <1500ns)") - print(f" invalidate_by_key(): {results['invalidate_key_p95']:>8.0f} ns (target: <1000ns)") - print(f" invalidate_by_namespace(100): {results['invalidate_ns_100_p95']:>8.0f} ns (target: <100000ns)") - - # Validate SLAs - failures = [] - if results["get_hit_p95"] >= 1500: - failures.append(f"get() hit: {results['get_hit_p95']:.0f}ns >= 1500ns") - if results["invalidate_key_p95"] >= 1000: - failures.append(f"Invalidate key: {results['invalidate_key_p95']:.0f}ns >= 1000ns") - if results["invalidate_ns_100_p95"] >= 100_000: - failures.append(f"Invalidate NS: {results['invalidate_ns_100_p95']:.0f}ns >= 100000ns") - - if failures: - raise AssertionError("L1 Invalidation SLA violations:\n" + "\n".join(failures)) - - print("\n✅ All L1 Invalidation SLAs validated") - print(" Invalidation features meet latency targets") diff --git a/tests/unit/test_l1_invalidation.py b/tests/unit/test_l1_invalidation.py deleted file mode 100644 index 6c0eeb6..0000000 --- a/tests/unit/test_l1_invalidation.py +++ /dev/null @@ -1,242 +0,0 @@ -"""Unit tests for L1Cache invalidation functionality.""" - -from cachekit.config.nested import L1CacheConfig -from cachekit.l1_cache import L1Cache - - -class TestL1CacheInvalidation: - """Test L1 cache invalidation operations.""" - - def test_invalidate_by_key_removes_entry(self): - """Test that invalidate_by_key removes specific entry.""" - config = L1CacheConfig(namespace_index=True) - cache = L1Cache(max_memory_mb=10, config=config) - - # Put multiple entries - cache.put("key1", b"value1", redis_ttl=100.0) - cache.put("key2", b"value2", redis_ttl=100.0) - cache.put("key3", b"value3", redis_ttl=100.0) - - # Invalidate key2 - result = cache.invalidate_by_key("key2") - assert result is True - - # Verify key2 is gone, others remain - hit1, val1 = cache.get("key1") - assert hit1 is True - assert val1 == b"value1" - - hit2, val2 = cache.get("key2") - assert hit2 is False - assert val2 is None - - hit3, val3 = cache.get("key3") - assert hit3 is True - assert val3 == b"value3" - - def test_invalidate_by_key_returns_false_for_missing(self): - """Test that invalidate_by_key returns False for non-existent key.""" - config = L1CacheConfig(namespace_index=True) - cache = L1Cache(max_memory_mb=10, config=config) - - result = cache.invalidate_by_key("nonexistent") - assert result is False - - def test_invalidate_by_namespace_clears_matching(self): - """Test that invalidate_by_namespace clears all entries in namespace.""" - config = L1CacheConfig(namespace_index=True) - cache = L1Cache(max_memory_mb=10, config=config) - - # Put entries in different namespaces - cache.put("key1", b"value1", redis_ttl=100.0, namespace="ns1") - cache.put("key2", b"value2", redis_ttl=100.0, namespace="ns1") - cache.put("key3", b"value3", redis_ttl=100.0, namespace="ns2") - cache.put("key4", b"value4", redis_ttl=100.0, namespace="ns2") - cache.put("key5", b"value5", redis_ttl=100.0) # No namespace - - # Invalidate ns1 - count = cache.invalidate_by_namespace("ns1") - assert count == 2 - - # Verify ns1 entries are gone - hit1, val1 = cache.get("key1") - assert hit1 is False - - hit2, val2 = cache.get("key2") - assert hit2 is False - - # Verify ns2 and no-namespace entries remain - hit3, val3 = cache.get("key3") - assert hit3 is True - - hit4, val4 = cache.get("key4") - assert hit4 is True - - hit5, val5 = cache.get("key5") - assert hit5 is True - - def test_invalidate_by_namespace_empty_namespace(self): - """Test invalidate_by_namespace on empty namespace returns 0.""" - config = L1CacheConfig(namespace_index=True) - cache = L1Cache(max_memory_mb=10, config=config) - - count = cache.invalidate_by_namespace("nonexistent") - assert count == 0 - - def test_invalidate_all_clears_everything(self): - """Test that invalidate_all removes all entries.""" - config = L1CacheConfig(namespace_index=True) - cache = L1Cache(max_memory_mb=10, config=config) - - # Put entries - cache.put("key1", b"value1", redis_ttl=100.0, namespace="ns1") - cache.put("key2", b"value2", redis_ttl=100.0, namespace="ns2") - cache.put("key3", b"value3", redis_ttl=100.0) - - # Invalidate all - count = cache.invalidate_all() - assert count == 3 - - # Verify all gone - hit1, val1 = cache.get("key1") - assert hit1 is False - - hit2, val2 = cache.get("key2") - assert hit2 is False - - hit3, val3 = cache.get("key3") - assert hit3 is False - - def test_namespace_index_tracks_entries(self): - """Test that namespace index correctly tracks entries.""" - config = L1CacheConfig(namespace_index=True) - cache = L1Cache(max_memory_mb=10, config=config) - - # Verify namespace index exists - assert hasattr(cache, "_namespace_index") - - # Put entries - cache.put("key1", b"value1", redis_ttl=100.0, namespace="ns1") - cache.put("key2", b"value2", redis_ttl=100.0, namespace="ns1") - cache.put("key3", b"value3", redis_ttl=100.0, namespace="ns2") - - # Verify index tracking - assert "key1" in cache._namespace_index["ns1"] - assert "key2" in cache._namespace_index["ns1"] - assert "key3" in cache._namespace_index["ns2"] - assert len(cache._namespace_index["ns1"]) == 2 - assert len(cache._namespace_index["ns2"]) == 1 - - # Invalidate and verify index cleanup - cache.invalidate_by_namespace("ns1") - assert "ns1" not in cache._namespace_index - assert len(cache._namespace_index["ns2"]) == 1 - - def test_no_index_falls_back_to_scan(self): - """Test that namespace invalidation works without index (O(n) scan fallback).""" - # Config WITHOUT namespace_index - config = L1CacheConfig(namespace_index=False) - cache = L1Cache(max_memory_mb=10, config=config) - - # Verify index does NOT exist - assert not hasattr(cache, "_namespace_index") - - # Put entries with namespaces - cache.put("key1", b"value1", redis_ttl=100.0, namespace="ns1") - cache.put("key2", b"value2", redis_ttl=100.0, namespace="ns1") - cache.put("key3", b"value3", redis_ttl=100.0, namespace="ns2") - - # Invalidate ns1 (should use O(n) fallback) - count = cache.invalidate_by_namespace("ns1") - assert count == 2 - - # Verify correct entries removed - hit1, val1 = cache.get("key1") - assert hit1 is False - - hit2, val2 = cache.get("key2") - assert hit2 is False - - hit3, val3 = cache.get("key3") - assert hit3 is True - - def test_invalidate_all_clears_namespace_index(self): - """Test that invalidate_all clears namespace index.""" - config = L1CacheConfig(namespace_index=True) - cache = L1Cache(max_memory_mb=10, config=config) - - # Put entries - cache.put("key1", b"value1", redis_ttl=100.0, namespace="ns1") - cache.put("key2", b"value2", redis_ttl=100.0, namespace="ns2") - - # Verify index populated - assert len(cache._namespace_index) > 0 - - # Invalidate all - cache.invalidate_all() - - # Verify index cleared - assert len(cache._namespace_index) == 0 - - def test_namespace_index_updated_on_overwrite(self): - """Test that namespace index is updated when entry is overwritten with different namespace.""" - config = L1CacheConfig(namespace_index=True) - cache = L1Cache(max_memory_mb=10, config=config) - - key = "test_key" - - # Put with ns1 - cache.put(key, b"value1", redis_ttl=100.0, namespace="ns1") - assert key in cache._namespace_index["ns1"] - - # Overwrite with ns2 - cache.put(key, b"value2", redis_ttl=100.0, namespace="ns2") - - # Verify index updated - assert key not in cache._namespace_index.get("ns1", set()) - assert key in cache._namespace_index["ns2"] - - def test_invalidate_by_namespace_with_no_namespace_entries(self): - """Test that entries without namespace are not affected by namespace invalidation.""" - config = L1CacheConfig(namespace_index=True) - cache = L1Cache(max_memory_mb=10, config=config) - - # Put entries with and without namespace - cache.put("key1", b"value1", redis_ttl=100.0, namespace="ns1") - cache.put("key2", b"value2", redis_ttl=100.0) # No namespace - cache.put("key3", b"value3", redis_ttl=100.0) # No namespace - - # Invalidate ns1 - count = cache.invalidate_by_namespace("ns1") - assert count == 1 - - # Verify no-namespace entries remain - hit2, val2 = cache.get("key2") - assert hit2 is True - - hit3, val3 = cache.get("key3") - assert hit3 is True - - def test_multiple_namespaces_independent(self): - """Test that multiple namespaces are independent.""" - config = L1CacheConfig(namespace_index=True) - cache = L1Cache(max_memory_mb=10, config=config) - - # Put entries in different namespaces - cache.put("key1", b"value1", redis_ttl=100.0, namespace="users") - cache.put("key2", b"value2", redis_ttl=100.0, namespace="products") - cache.put("key3", b"value3", redis_ttl=100.0, namespace="orders") - - # Invalidate products - count = cache.invalidate_by_namespace("products") - assert count == 1 - - # Verify only products invalidated - hit1, _ = cache.get("key1") - assert hit1 is True - - hit2, _ = cache.get("key2") - assert hit2 is False - - hit3, _ = cache.get("key3") - assert hit3 is True From 383eebae1e941d1e5c0af9da93c209709ba4576a Mon Sep 17 00:00:00 2001 From: Ray Walker Date: Wed, 5 Aug 2026 09:08:30 +1000 Subject: [PATCH 2/2] docs(config): name CachekitIO as the io() preset's managed backend (LAB-1433) CodeRabbit review on #258: the io() row said "Managed SaaS backend" generically instead of naming CachekitIO, the established service name used elsewhere in the docs. --- docs/configuration.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/configuration.md b/docs/configuration.md index 294c17e..a4df925 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -347,7 +347,7 @@ def secure_function(): | `dev()` | L1-only¹ | ❌ | 100 MB | Verbose logs, no Prometheus | | `production()` | L1-only¹ | ✓ | 100 MB | Full observability | | `secure()` | L1-only¹ | ✓ | 100 MB | AES-256-GCM encryption required | -| `io()` | ✓ | ✓ | 100 MB | Managed SaaS backend (closed beta — [request access](https://cachekit.io)); past-TTL [SWR](#stale-while-revalidate-stale_ttl) default-on (`stale_ttl = ttl`) | +| `io()` | ✓ | ✓ | 100 MB | CachekitIO managed SaaS backend (closed beta — [request access](https://cachekit.io)); past-TTL [SWR](#stale-while-revalidate-stale_ttl) default-on (`stale_ttl = ttl`) | ¹ Within-TTL refresh-ahead SWR runs **only in L1-only mode** (`backend=None`), where the SDK re-runs your function in the background past `ttl * swr_threshold_ratio`. With a backend configured, these presets have no SWR — `swr_enabled` has no effect outside L1-only mode (Redis exposes no read-side freshness signal). The only backed SWR is `@cache.io`'s past-TTL [`stale_ttl`](#stale-while-revalidate-stale_ttl) mode.