Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .secrets.baseline
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand Down Expand Up @@ -887,5 +887,5 @@
}
]
},
"generated_at": "2026-07-29T00:15:05Z"
"generated_at": "2026-08-04T22:16:14Z"
}
1 change: 0 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 | - | ✅ | - | ✅ | ✅ |
Expand Down
19 changes: 8 additions & 11 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
Expand All @@ -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`)

Expand Down Expand Up @@ -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 | 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.

Expand Down
22 changes: 9 additions & 13 deletions docs/features/l1-invalidation.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
)
```

Expand All @@ -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

Expand All @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down
6 changes: 0 additions & 6 deletions src/cachekit/config/decorator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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),
Expand Down
2 changes: 0 additions & 2 deletions src/cachekit/config/nested.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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.
Expand Down
85 changes: 2 additions & 83 deletions src/cachekit/l1_cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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."""
Expand Down Expand Up @@ -60,15 +59,13 @@ def __init__(
max_memory_mb: int = 100,
ttl_buffer_seconds: float = 1.0,
namespace: str = "default",
config: Optional[Any] = None,
):
"""Initialize L1 cache.

Args:
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
Expand All @@ -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,
Expand Down Expand Up @@ -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.

Expand All @@ -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
Expand Down Expand Up @@ -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)

Expand All @@ -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.

Expand Down Expand Up @@ -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:
Expand Down
Loading
Loading