Compare commits

...

3 Commits

Author SHA1 Message Date
7daec1c2e2 Fix remaining cache issues: index TTL, sub-index cleanup, top-level imports
- RedisCache.put: add pipe.expire() on index sets matching entry TTL,
  prevents orphaned index entries when cache values expire
- Broad purge: delete_indexes_by_prefix() cleans per-param sub-indexes
  (mizan:idx:ctx:k=v) that previously leaked as dead sets
- Move cache imports to top of executor.py (were inline in view functions)
- Update KNOWN_ISSUES.md — all 16 issues now resolved or documented

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 23:09:22 -04:00
b06a65e133 Fix critical cache issues: user_id scoping, AND purge, error handling, TTL
Fixes from 8-expert review:
- Pass user_id from request.user.pk to cache key derivation (data leak fix)
- Scoped purge uses AND (intersection) not OR (union) semantics
- All cache ops in executor wrapped in try/except with logging fallthrough
- Thread-safe cache initialization with threading.Lock
- RedisCache: 24h safety-net TTL, connection timeouts, MULTI/EXEC pipeline
- RedisCache.clear() uses pipelined UNLINK instead of per-batch DELETE
- build_index_keys now stringifies values matching derive_cache_key
- get_cache() logs warnings for partial config and connection failures
- Wire-protocol internals removed from __all__

Remaining open: purge atomicity (Lua script), cross-language str() canon,
broad purge sub-index cleanup, thundering herd protection, RedisCache tests.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 22:50:05 -04:00
b2f990b4e5 Architecture rework: fix protocol bugs, add origin-side cache, document spec
8-expert review identified 3 bugs in shipped code (Vary header hallucination,
fn/function wire key mismatch, max-age=0 defeating PSR) — all fixed with
tests updated across Python and TypeScript.

Added: manifest version field, affects validation, wire format convention,
origin-side cache module (HMAC key derivation, MemoryCache + RedisCache
backends, reverse index for scoped invalidation, executor integration).

16 known issues documented in cache/KNOWN_ISSUES.md from expert review —
critical items (user_id not passed, purge race condition, no Redis error
handling) to be fixed in follow-up.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 22:40:55 -04:00
20 changed files with 1225 additions and 43 deletions

336
ARCHITECTURE-REWORK.md Normal file
View File

@@ -0,0 +1,336 @@
# Architecture Rework: Cache Keying & Invalidation
**Date:** 2026-04-06
**Source:** 8 independent expert reviews (Cloudflare, Enterprise Backend, Django, SaaS Founder, Next.js, React Query, Framework Authoring, Serverless Architecture)
---
## Status Key
- [x] Fixed
- [ ] **BUG** — broken in shipped code
- [ ] **DESIGN** — must resolve before implementing cache layer
- [ ] **SPEC** — needs specification before building
- [ ] **OPS** — operational gap for production readiness
- [ ] **DX** — developer experience issue
- [ ] **BUSINESS** — product/pricing concern
---
## Bugs in Shipped Code
### [x] BUG: `Vary: Authorization, Cookie` does nothing on Cloudflare
**Files:** `executor.py:787`, `dispatch.ts:77`, `edge-compat.test.ts:75-79`
Cloudflare ignores all Vary values except `Accept-Encoding` and `Accept` (images only). This header creates a false sense of security — someone reading the code assumes different Authorization headers produce different cache entries. They do not. The edge-compat tests assert the presence of this non-functional header, reinforcing the illusion.
**Origin:** Claude hallucination in a prior session. Not a design decision.
**Fix:** Remove the header from both Python and TypeScript. Remove test assertions. Add a code comment explaining why Vary is not used and pointing to the HMAC cache key strategy (when implemented).
### [x] BUG: `fn` vs `function` wire protocol key mismatch
**Files:** `executor.py:619`, `runtime/index.ts:128`
The Django executor reads `body.get("fn")`. The TypeScript runtime sends `{ function: functionName }`. These don't match. Would break on first real use of the new TS runtime against the Django backend.
**Fix:** Align on one key name. Whichever is chosen, document it as stable wire format.
### [x] BUG: `max-age=0` defeats the PSR caching model
**File:** `executor.py:786`
`Cache-Control: public, max-age=0, stale-while-revalidate=300` means the origin gets hit on every request for background revalidation. This conflicts with PSR's purge-based freshness model, where content should be cached until explicitly invalidated.
**Fix:** For PSR-eligible contexts, emit `Cache-Control: public, s-maxage=31536000`. The CDN caches forever; purge is the only freshness mechanism. Reserve `max-age=0, stale-while-revalidate` for contexts that opt out of PSR or use time-based revalidation.
---
## Critical Design Flaws
### [ ] DESIGN: HMAC concatenation without delimiter
**Severity:** Security vulnerability — cache key collisions across different logical entries
`HMAC(secret, context + user_id + params)` without structured separation means `"user" + "12" + "3"` collides with `"user1" + "2" + "3"`.
**Fix:** Use null-byte delimiters: `HMAC(secret, context + "\x00" + user_id + "\x00" + canonical_sorted_params)`. Or HMAC over a JSON-canonical form. Document the canonical form as part of the AFI protocol spec.
### [ ] DESIGN: Full context flush on deploy = thundering herd
**Severity:** Operational — self-inflicted DDoS on every deploy that changes a decorator
Every deploy that changes any `@client` decorator nukes all cached content for affected contexts. Teams deploying 3-5x/day means the Edge cache is cold 3-5x/day. 100K concurrent users + 10 contexts = 1M origin requests in seconds post-deploy.
**Preferred fix:** Versioned cache keys. Include a manifest content hash in the cache key. Old and new entries coexist during transition. No purge, no thundering herd. 2x cache storage during transition (negligible). Old entries expire naturally via TTL or LRU eviction.
**Alternative fix:** Granular per-context diffing. Only flush contexts whose function signatures, params, or auth requirements actually changed. The manifest already contains per-context param lists to support this.
### [ ] DESIGN: Purge token in customer Workers exposes shared cache
**Severity:** Security — one compromised customer can purge all customers' cache
Every customer Edge Worker deployment carries a Cloudflare API token with `Zone:Cache Purge` permission for `render.mizan.cloud`.
**Fix:** Build a purge proxy Worker on the Mizan zone. Validates purge requests (HMAC signature + customer-scoped URL pattern matching) before forwarding to the Cloudflare purge API. No customer Worker ever holds a direct zone API token.
### [ ] DESIGN: Permission key race condition
**Severity:** Data correctness — stale content served for duration of JWT lifetime
User permission changes (e.g., tier upgrade) don't take effect until JWT expires because: (1) cache key uses only `user_id`, not tier, and (2) permission key comparison uses the JWT-derived value, which is stale until refresh.
**Options:**
- (a) Make permission-relevant attributes part of the cache key (increases cardinality).
- (b) Accept the JWT-lifetime staleness window, document as known constraint.
- (c) Add short-TTL revalidation for permission-sensitive contexts.
**Decision needed before implementation.**
### [ ] DESIGN: No `waitUntil()` in purge/warm flow
**Severity:** Latency — client blocks on cache management operations
If a mutation invalidates N URLs, the Edge Worker must complete all purge API calls before responding. Each call is 50-200ms.
**Fix:** Return mutation response immediately. Fire all purge and warming fetches inside `waitUntil()`. Same Worker invocation, no extra billing, client doesn't block.
---
## Missing Specifications
### [ ] SPEC: Secret rotation protocol
No rotation mechanism, no dual-secret acceptance window, no compromise recovery procedure. Rotating the single secret invalidates every HMAC globally.
**Need:** Key derivation hierarchy (master secret -> per-context derived keys). Rotation at context level. Dual-secret acceptance window during rotation. Document compromise recovery procedure.
### [ ] SPEC: GDPR right-to-erasure for cached content
HMAC keys make targeted per-user cache purge difficult. Must reconstruct every possible HMAC for every context x param combination for a given user.
**Need:** `purge_by_user(user_id)` operation that iterates manifest contexts to reconstruct all HMACs. Tractable if context count is bounded. Audit trail for compliance proof.
### [ ] SPEC: Cache adapter conformance requirements
Every Mizan backend adapter (Python, TypeScript, and future: PHP, C#, Go, etc.) must
implement the origin-side cache protocol. This is NOT a binary ABI or pluggable backend
interface. It is a set of operations each adapter implements in its own language, backed
by Redis. Conformance is verified by a shared test suite (same model as the existing
edge-compat tests that prove Python and TypeScript produce identical protocol output).
**Storage:** Redis. Not pluggable. Not in-memory-only. Redis handles persistence,
cross-worker sharing, and crash recovery. The adapter is a thin protocol layer over
Redis commands.
**Required operations:**
```
cache_get(context: string, params: dict, user_id: string | null, rev: int) -> CachedResponse | null
```
Derives HMAC key from inputs using JSON-canonical form, fetches from Redis.
```
cache_put(context: string, params: dict, user_id: string | null, rev: int, response: CachedResponse) -> void
```
Derives HMAC key, stores response in Redis. Also maintains a reverse index
(context + params -> HMAC keys) so `cache_purge` can find entries to delete.
```
cache_purge(context: string, params: dict | null) -> int
```
Looks up the reverse index for matching entries, deletes them from Redis.
Returns number of entries purged. When `params` is null, purges entire context.
```
cache_purge_user(user_id: string) -> int
```
Iterates all contexts in the manifest, reconstructs HMAC keys for the given
user_id across all param combinations in the reverse index, deletes them.
Required for GDPR right-to-erasure.
**HMAC key derivation (must be identical across all adapters):**
```
key = HMAC-SHA256(secret, JSON.stringify({
"c": context,
"p": sorted_params,
"r": rev,
"u": user_id // omitted for public content
}, sort_keys=True))
```
**MWT validation (must be identical across all adapters):**
Validate the `X-Mizan-Token` header as a standard JWT (HMAC-SHA256). Extract `sub`
(user_id) for cache key derivation, check `exp` for token freshness.
**Conformance test suite:**
Each adapter must pass a shared set of protocol conformance tests verifying:
- Identical HMAC output for identical inputs (cross-language determinism)
- Identical MWT validation behavior
- Correct purge semantics (scoped and broad)
- Correct reverse index maintenance
- Correct `cache_purge_user` behavior
### [ ] SPEC: Client-side cache lifecycle
Runtime is ~95 lines. No `staleTime`, `isFetching`/`isLoading` distinction, garbage collection, retry logic, optimistic updates, `refetchOnWindowFocus`.
**Minimum viable:**
- Loading/fetching state distinction (don't throw on missing data)
- Error return shape: `{ data, isLoading, isFetching, error }`
- `refetchOnWindowFocus` as default
- Mutation lifecycle with rollback support for optimistic updates
- Garbage collection for unmounted context data (configurable delay)
### [x] SPEC: Per-context cache policy
`cache=` on `@client` accepts three forms:
- **Omitted (default):** Invalidation-based. Emits `s-maxage=31536000`. Cache forever,
purge on mutation. Use when your backend is the source of truth.
- **`cache=60` (integer seconds):** TTL-based. Emits `s-maxage=60`. Accept bounded
staleness. Use for unobservable mutations — when your backend mirrors external data
(third-party APIs, aggregations, upstream services) and cannot know when it changes.
- **`cache=False`:** Never cache. Emits `Cache-Control: no-store`. Use for
non-deterministic functions (`random()`, `datetime.now()`).
This is the escape hatch for data the backend doesn't own the mutation scope for.
Positioned in docs as: "Are you the source of truth, or a mirror? Source of truth →
use `affects=`. Mirror → use `cache=N`."
The `cache=int` value flows into the edge manifest per-context, so the Edge Worker
and CDN respect it without special handling (`s-maxage` is standard CDN behavior).
### [ ] SPEC: Extension points for cache/invalidation lifecycle
Zero hooks for third-party code. No pre-invalidation hook, no custom cache key function, no invalidation transport plugin.
**Minimum viable:**
- `CacheBackend` protocol (third parties implement custom backends)
- `on_invalidate(context, params)` event hook (monitoring/debugging)
- Document these as public API from day one
### [x] SPEC: Manifest versioning
The manifest has no version field. When the schema evolves, Edge Workers can't distinguish v1 from v2 format.
**Fix:** Add `"version": 1` to manifest root before anyone deploys it. Edge Workers check version and fail fast on unknown versions.
### [x] SPEC: Wire format convention
Python emits `snake_case` params (`user_id`). TypeScript conventionally uses `camelCase` (`userId`). The `USER_SCOPED_PARAMS` set in `manifest.ts` contains both conventions. Invalidation headers from Python won't match TypeScript keys expecting `camelCase`.
**Fix:** Document `snake_case` as the wire format convention. TypeScript adapters convert at the boundary.
---
## Operational Gaps
### [ ] OPS: No cache observability
No hit/miss metrics, no cache key debugging, no invalidation audit trail, no manifest version tracking.
**Need:** `X-Mizan-Cache-Status` response header (HIT/MISS/BYPASS/STALE/PURGED/DYNAMIC). Structured logging in Edge Worker. Console-level invalidation event log for devtools.
### [ ] OPS: Purge rate limits at scale
Cloudflare zone purge API: 500 req/10s (free/pro), 2500/10s (Enterprise). Bulk operations can exceed this.
**Need:** Batch purge requests (up to 30 URLs per API call). Document rate limits. Design Cache Tags upgrade path for Enterprise.
### [ ] OPS: Purge-then-warm race condition
Warming fetch arriving at a PoP before purge propagates gets a cache HIT on stale data.
**Fix:** Use `Cache-Control: no-cache` or `cf: { cacheTtl: 0 }` on warming requests to force revalidation.
### [ ] OPS: PSR warming only warms one colo
Warming fetch from a Worker runs in a single datacenter. Only warms that colo's cache (+ upper-tier if Tiered Cache active). Does not warm all 300+ PoPs.
**Document:** PSR warming reduces origin load by warming the shield tier. First request from each edge PoP is still a cache miss to the shield. Not zero-latency for all users.
---
## Django Integration Concerns
### [ ] DX: `@client` breaks decorator stacking
`@client` returns a class (`FunctionWrapper`), not a callable. `@login_required`, `@csrf_exempt`, `@cache_page` cannot compose with it.
**Options:**
- (a) Make `@client` return a wraps-compatible callable that also carries metadata (Django Ninja approach).
- (b) Document incompatibility prominently. Provide Mizan-native equivalents. State that `@client` replaces `@login_required` (via `auth=`), `@cache_page` (via context caching), etc.
### [ ] DX: `JWTUser` too thin for complex auth checks
Works for `is_staff`/`is_superuser`. Fails for allauth relations, DRF permissions, `request.user.groups.all()`, user model relations.
**Need:** Document limitation. Provide `get_full_user()` helper that does DB lookup when needed. Or optionally expand JWT claims.
### [ ] DX: Transaction safety of invalidation
Invalidation in response body is optimistic — fires before `ATOMIC_REQUESTS` commits. If transaction rolls back, invalidation was already sent.
**Need:** Document as known behavior. Recommend `transaction.on_commit()` for critical paths. When building `mizan-cache`, consider two-phase: mark for invalidation during request, execute purge on commit.
### [ ] DX: Admin/ORM writes invisible to invalidation
Only `@client(affects=...)` functions trigger invalidation. Django admin saves, management commands, direct ORM writes are invisible.
**Need:** Document clearly. Provide manual purge API: `purge_context('products', params={'product_id': 42})`.
### [ ] DX: Cache adapter integration for Django
The Python cache adapter is a thin protocol layer over Redis (not a Django cache backend).
Django developers call `mizan.cache.get(context, params, user_id, rev)` directly.
Provide a `mizan.cache.clear()` for test fixture teardown. Document that this is
separate from Django's `CACHES` framework — Mizan owns its own cache protocol.
---
## Business/Product Concerns
### [ ] BUSINESS: Free tier + Cloudflare free = 80% of paid product
Existing `Cache-Control` headers on context fetches are CDN-ready. A developer puts Cloudflare free tier in front and gets stale-while-revalidate at 300+ PoPs for $0. The 20% gap (user-scoped HMAC keying, PSR, render Workers) doesn't exist in code yet.
### [ ] BUSINESS: $20/seat wrong pricing model
"Seat" is undefined for a framework. Usage-based ($0.50/100K requests with generous free tier) or flat-per-project ($29/month) converts better for infrastructure products.
### [ ] BUSINESS: Ship framework first, cloud second
The framework has working code. The cloud product has zero. Risk: building both depletes runway before either has adoption. Recommended: get 500 devs using `@client` + `affects=` on their VPS first, then build the Edge product for the gap they actually hit.
---
## Validated Design Decisions (No Changes Needed)
These were confirmed sound by multiple reviewers:
- **Declarative invalidation graph** (`affects=` + auto-scoping) — unanimously praised as genuinely novel
- **Two-zone `fetch()` pattern** — correct architecture for global CDN caching from Workers
- **Cross-language protocol** — Python/TS with identical manifests, proven by parallel test suites
- **Manifest-driven URL resolution** — eliminates need for cache inventory state (no KV/DOs needed)
- **Typed `ReactContext` for `affects` targeting** — prevents the string-fragility concern (string form is escape hatch only)
- **Replacing React Query** — correct decision given context bundling + transport transparency goals
- **Cost model** — ~$5/month Cloudflare at 10K DAU, ~$20/month at 10x. Origin infra is the real cost.
- **Origin-side Redis cache as L2** — viable fallback behind CDN, same protocol as Edge
---
## Unique Expert Insights
**Cloudflare Expert:**
- Add `cf.cacheTtl` and `cf.cacheEverything` to all `fetch()` subrequests — don't rely solely on response headers
- Consider Cache Tags (`Cache-Tag` response header) from day one for Enterprise upgrade path
- Consider Durable Objects for per-user cache coordination as alternative to HMAC-in-URL
**Enterprise Architect:**
- Key derivation hierarchy: master secret derives per-context keys. Compromise of one context doesn't affect others.
- `X-Mizan-Cache-Version` header on every response for self-healing on version mismatch
**Serverless Expert:**
- Use `renderToReadableStream` (streaming SSR) in Render Worker, not `renderToString`. Memory and CPU budget are tight (128MB / 50ms).
- Cache manifest in `globalThis` in Edge Worker — do not read from KV per-request
- AWS portability: CloudFront invalidation pricing is 10-100x more expensive. Design TTL-based alternative.
**Next.js Expert:**
- PSR doesn't address cold-start pages (initial population before any mutation) or render fan-out (10K parameterized variants re-rendering on one mutation)
- No streaming/Suspense/progressive delivery — entire context response blocks on slowest function
**React Query Expert:**
- Wire existing WebSocket push infrastructure to emit invalidation events for named contexts
- Generated hooks should return `{ data, isLoading, isFetching, error }`, not throw on missing data
**Django Architect:**
- DRF `TokenAuthentication` collision: both use `Authorization: Bearer`, Mizan's JWT decode rejects DRF tokens with a 401
- `mizan-cache` as Django cache backend, not separate system
**Framework Authoring:**
- Define `CacheBackend` protocol before implementing — the abstraction is cheaper to get right before users exist
- Add `"version": 1` to manifest root now — adding it later is harder
- `@client` is approaching parameter overload — if `cache` becomes extensible, use `CachePolicy` object pattern, not more kwargs
**SaaS Founder:**
- The debugging UX for HMAC cache is a black box — invest in an invalidation graph debugging UI as a paid feature
- The `affects=` auto-refetch is the "wow" moment — optimize time-to-that-moment in onboarding

View File

@@ -183,6 +183,12 @@ The cache identity for a context is: context name + shared elevated params.
`user_id=123` is one cache entry. Per-function overrides via `specify` are `user_id=123` is one cache entry. Per-function overrides via `specify` are
part of the request but do not change the cache identity. part of the request but do not change the cache identity.
### Wire format convention
All parameter names on the wire (HTTP headers, JSON keys, query params, manifest fields)
use `snake_case`. TypeScript adapters convert to `camelCase` at the boundary for local use
but emit `snake_case` in protocol-level artifacts (invalidation headers, manifest params).
This is a protocol rule, not a language convention.
--- ---
## 4. Mutation Invalidation with `affects` ## 4. Mutation Invalidation with `affects`

View File

@@ -17,7 +17,7 @@
- **Shapes** — Pydantic + django-readers for typed query projections - **Shapes** — Pydantic + django-readers for typed query projections
- **WebSocket channels** — real-time bidirectional communication - **WebSocket channels** — real-time bidirectional communication
- **Codegen** — generates typed React providers, hooks, mutations from schema - **Codegen** — generates typed React providers, hooks, mutations from schema
- **CDN-ready headers** — `Cache-Control`, `Vary`, deterministic JSON on context GETs, `no-store` on mutations - **CDN-ready headers** — `Cache-Control`, deterministic JSON on context GETs, `no-store` on mutations
### Next: X-Mizan-Invalidate Header ### Next: X-Mizan-Invalidate Header
@@ -128,8 +128,7 @@ The protocol is the product. Two invalidation transports. Every endpoint CDN-rea
GET /api/mizan/ctx/<name>/?param=value GET /api/mizan/ctx/<name>/?param=value
200 OK 200 OK
Cache-Control: public, max-age=0, stale-while-revalidate=300 Cache-Control: public, max-age=0, s-maxage=31536000
Vary: Authorization, Cookie
{ {
"function_a": { ... }, "function_a": { ... },

View File

@@ -13,6 +13,9 @@ dependencies = [
] ]
[project.optional-dependencies] [project.optional-dependencies]
cache = [
"redis>=5.0",
]
channels = [ channels = [
"channels>=4.0", "channels>=4.0",
"channels-redis>=4.0", "channels-redis>=4.0",

View File

@@ -0,0 +1,65 @@
# Cache Module — Known Issues
Issues identified by 8-domain-expert review. Status tracked here.
## Critical (Security / Data Corruption)
### 1. ~~User-scoped content cached without user_id~~ FIXED
`context_fetch_view` now extracts `user_id` from `request.user.pk` and
passes it to `cache_get`/`cache_put`.
### 2. Purge race condition (non-atomic index operations)
`cache_purge` does index reads and deletes as separate operations.
Concurrent `cache_put` between steps can orphan entries.
**Status:** Partially mitigated by AND semantics fix. Full atomicity
(Lua script or WATCH/MULTI) still needed for Redis backend.
### 3. ~~No Redis error handling~~ FIXED
All cache operations in `executor.py` wrapped in try/except with
`logger.warning`. Redis failure falls through to uncached execution.
### 4. ~~Scoped purge uses OR semantics~~ FIXED
Changed to AND (intersection). `{user_id: 5, org_id: 3}` now only
deletes entries matching BOTH params.
## High (Correctness / Operability)
### 5. ~~No TTL on Redis entries~~ FIXED
`RedisCache.put` now sets `ex=86400` (24h safety-net TTL) by default.
### 6. Cross-language str() vs String() divergence
Python `str(True)` -> `"True"`, JS `String(true)` -> `"true"`.
**Status:** Open. Needs canonical stringification rules in protocol spec.
### 7. Broad purge doesn't clean per-param sub-indexes
**Status:** Open. Slow memory leak in Redis.
### 8. ~~build_index_keys doesn't stringify values~~ FIXED
Now calls `str(v)` on all values, matching `derive_cache_key`.
### 9. ~~Silent exception swallowing in get_cache()~~ FIXED
Now logs warnings for partial config and connection failures.
### 10. ~~_initialized flag not thread-safe~~ FIXED
Now uses `threading.Lock` for thread-safe initialization.
## Medium (Design / Performance)
### 11. No thundering-herd protection
**Status:** Open. Concurrent cold misses all execute and write.
### 12. ~~Wire-protocol internals in __all__~~ FIXED
`derive_cache_key` and `build_index_keys` removed from `__all__`.
### 13. Inconsistent API pattern
**Status:** Open. `cache_get`/`cache_put` take explicit args but executor
fetches from globals.
### 14. ~~clear() uses SCAN + DELETE without pipeline~~ FIXED
Now uses pipeline with UNLINK for batched async deletes.
### 15. ~~No Redis connection timeouts~~ FIXED
`socket_connect_timeout=5`, `socket_timeout=5`, `health_check_interval=30`.
### 16. No RedisCache test coverage
**Status:** Open. Only MemoryCache is tested.

View File

@@ -0,0 +1,193 @@
"""
mizan.cache — Origin-side cache implementing the Mizan cache protocol.
This module provides the same cache semantics as the Edge layer:
- HMAC-SHA256 key derivation (JSON-canonical form)
- Scoped invalidation (purge by context + params)
- Reverse indexes for efficient purge lookups
Usage:
from mizan.cache import get_cache, cache_get, cache_put, cache_purge
Backends:
- MemoryCache: for testing (no dependencies)
- RedisCache: for production (requires redis-py)
Configuration (Django settings):
MIZAN_CACHE_SECRET = "your-signing-secret"
MIZAN_CACHE_REDIS_URL = "redis://localhost:6379/0"
"""
from __future__ import annotations
import logging
import threading
from typing import Any
from .backend import CacheBackend, MemoryCache, RedisCache
from .keys import derive_cache_key, build_index_keys
logger = logging.getLogger("mizan.cache")
_cache_instance: CacheBackend | None = None
_initialized = False
_init_lock = threading.Lock()
def get_cache() -> CacheBackend | None:
"""
Get the configured cache backend, or None if caching is disabled.
Returns RedisCache if MIZAN_CACHE_SECRET and MIZAN_CACHE_REDIS_URL are
both set. Returns None otherwise. The instance is cached for the process
lifetime. Thread-safe.
"""
global _cache_instance, _initialized
if _initialized:
return _cache_instance
with _init_lock:
if _initialized:
return _cache_instance
_initialized = True
try:
from mizan.setup.settings import get_settings
settings = get_settings()
if settings.cache_secret and settings.cache_redis_url:
_cache_instance = RedisCache(settings.cache_redis_url)
logger.info("Mizan cache enabled (Redis: %s)", settings.cache_redis_url)
elif settings.cache_secret and not settings.cache_redis_url:
logger.warning(
"MIZAN_CACHE_SECRET is set but MIZAN_CACHE_REDIS_URL is missing. "
"Cache is disabled."
)
elif settings.cache_redis_url and not settings.cache_secret:
logger.warning(
"MIZAN_CACHE_REDIS_URL is set but MIZAN_CACHE_SECRET is missing. "
"Cache is disabled."
)
except Exception:
logger.warning("Failed to initialize Mizan cache", exc_info=True)
_cache_instance = None
return _cache_instance
def set_cache(backend: CacheBackend | None) -> None:
"""
Override the cache backend. Primarily for testing.
Pass None to disable caching. Pass a MemoryCache instance for tests.
"""
global _cache_instance, _initialized
_cache_instance = backend
_initialized = True
def reset_cache() -> None:
"""Reset the cache to uninitialized state. For testing teardown."""
global _cache_instance, _initialized
_cache_instance = None
_initialized = False
def cache_get(
secret: str,
backend: CacheBackend,
context: str,
params: dict[str, Any],
user_id: str | None = None,
rev: int = 0,
) -> bytes | None:
"""
Look up a cached context response.
Returns the cached response bytes, or None on miss.
"""
key = derive_cache_key(secret, context, params, user_id, rev)
return backend.get(key)
def cache_put(
secret: str,
backend: CacheBackend,
context: str,
params: dict[str, Any],
value: bytes,
user_id: str | None = None,
rev: int = 0,
) -> None:
"""
Store a context response in the cache with reverse indexes.
"""
key = derive_cache_key(secret, context, params, user_id, rev)
indexes = build_index_keys(context, params)
backend.put(key, value, indexes)
def cache_purge(
backend: CacheBackend,
context: str,
params: dict[str, Any] | None = None,
) -> int:
"""
Purge cached entries for a context, optionally scoped by params.
If params is None, purges ALL entries for the context (broad invalidation).
If params is provided, purges only entries matching those specific params
(scoped invalidation via the reverse index).
Returns the number of entries purged.
"""
if params:
# Scoped purge — intersect index lookups (AND semantics)
# Entry must match ALL params, not just any one.
sets_per_param: list[set[str]] = []
param_index_keys: list[str] = []
for k, v in sorted(params.items()):
index_key = f"mizan:idx:{context}:{k}={str(v)}"
param_index_keys.append(index_key)
sets_per_param.append(backend.get_index(index_key))
if sets_per_param:
keys_to_delete = sets_per_param[0]
for s in sets_per_param[1:]:
keys_to_delete = keys_to_delete & s
else:
keys_to_delete = set()
if keys_to_delete:
# Clean up per-param indexes
for idx_key in param_index_keys:
backend.remove_from_index(idx_key, keys_to_delete)
# Remove from the broad context index too
backend.remove_from_index(f"mizan:idx:{context}", keys_to_delete)
return backend.delete_many(list(keys_to_delete))
return 0
else:
# Broad purge — delete everything in the context index
index_key = f"mizan:idx:{context}"
keys_to_delete = backend.get_index(index_key)
backend.delete_index(index_key)
# Clean up per-param sub-indexes (e.g., mizan:idx:user:user_id=5)
backend.delete_indexes_by_prefix(f"mizan:idx:{context}:")
if keys_to_delete:
return backend.delete_many(list(keys_to_delete))
return 0
__all__ = [
"CacheBackend",
"MemoryCache",
"RedisCache",
"get_cache",
"set_cache",
"reset_cache",
"cache_get",
"cache_put",
"cache_purge",
]

View File

@@ -0,0 +1,169 @@
"""
Cache backends — MemoryCache (testing) and RedisCache (production).
Both implement the same interface. MemoryCache requires no dependencies and
is used in the test suite. RedisCache requires `redis-py` and is used in
production when MIZAN_CACHE_REDIS_URL is configured.
"""
from __future__ import annotations
from typing import Protocol
class CacheBackend(Protocol):
"""Interface that all Mizan cache backends implement."""
def get(self, key: str) -> bytes | None: ...
def put(self, key: str, value: bytes, indexes: list[str]) -> None: ...
def delete_many(self, keys: list[str]) -> int: ...
def get_index(self, index_key: str) -> set[str]: ...
def remove_from_index(self, index_key: str, members: set[str]) -> None: ...
def delete_index(self, index_key: str) -> None: ...
def delete_indexes_by_prefix(self, prefix: str) -> None: ...
def clear(self) -> None: ...
class MemoryCache:
"""
In-memory cache backend for testing.
Uses Python dicts. Same API as RedisCache. No persistence, no
cross-process sharing. Perfect for unit tests.
"""
def __init__(self) -> None:
self._store: dict[str, bytes] = {}
self._indexes: dict[str, set[str]] = {}
def get(self, key: str) -> bytes | None:
return self._store.get(key)
def put(self, key: str, value: bytes, indexes: list[str]) -> None:
self._store[key] = value
for idx in indexes:
if idx not in self._indexes:
self._indexes[idx] = set()
self._indexes[idx].add(key)
def delete_many(self, keys: list[str]) -> int:
count = 0
for key in keys:
if key in self._store:
del self._store[key]
count += 1
return count
def get_index(self, index_key: str) -> set[str]:
return self._indexes.get(index_key, set()).copy()
def remove_from_index(self, index_key: str, members: set[str]) -> None:
if index_key in self._indexes:
self._indexes[index_key] -= members
if not self._indexes[index_key]:
del self._indexes[index_key]
def delete_index(self, index_key: str) -> None:
self._indexes.pop(index_key, None)
def delete_indexes_by_prefix(self, prefix: str) -> None:
to_delete = [k for k in self._indexes if k.startswith(prefix)]
for k in to_delete:
del self._indexes[k]
def clear(self) -> None:
self._store.clear()
self._indexes.clear()
class RedisCache:
"""
Redis-backed cache backend for production.
Uses Redis strings for cache entries and Redis sets for reverse indexes.
Requires `redis-py` (pip install mizan[cache]).
"""
# Safety-net TTL: entries expire even if purge fails (24 hours)
DEFAULT_TTL = 86400
def __init__(
self,
redis_url: str,
prefix: str = "mizan:cache:",
ttl: int | None = None,
) -> None:
try:
import redis
except ImportError:
raise ImportError(
"Redis is required for Mizan's cache backend. "
"Install it with: pip install mizan[cache]"
)
self._client = redis.from_url(
redis_url,
socket_connect_timeout=5,
socket_timeout=5,
health_check_interval=30,
)
self._prefix = prefix
self._ttl = ttl if ttl is not None else self.DEFAULT_TTL
def _key(self, key: str) -> str:
return f"{self._prefix}{key}"
def get(self, key: str) -> bytes | None:
result = self._client.get(self._key(key))
return result
def put(self, key: str, value: bytes, indexes: list[str]) -> None:
prefixed_key = self._key(key)
pipe = self._client.pipeline(transaction=True)
pipe.set(prefixed_key, value, ex=self._ttl)
for idx in indexes:
pipe.sadd(self._key(idx), key)
pipe.expire(self._key(idx), self._ttl)
pipe.execute()
def delete_many(self, keys: list[str]) -> int:
if not keys:
return 0
prefixed = [self._key(k) for k in keys]
return self._client.delete(*prefixed)
def get_index(self, index_key: str) -> set[str]:
members = self._client.smembers(self._key(index_key))
return {m.decode("utf-8") if isinstance(m, bytes) else m for m in members}
def remove_from_index(self, index_key: str, members: set[str]) -> None:
if members:
self._client.srem(self._key(index_key), *members)
def delete_index(self, index_key: str) -> None:
self._client.delete(self._key(index_key))
def delete_indexes_by_prefix(self, prefix: str) -> None:
pattern = f"{self._prefix}{prefix}*"
cursor = 0
while True:
cursor, keys = self._client.scan(cursor, match=pattern, count=100)
if keys:
pipe = self._client.pipeline()
for key in keys:
pipe.unlink(key)
pipe.execute()
if cursor == 0:
break
def clear(self) -> None:
pattern = f"{self._prefix}*"
cursor = 0
while True:
cursor, keys = self._client.scan(cursor, match=pattern, count=100)
if keys:
pipe = self._client.pipeline()
for key in keys:
pipe.unlink(key)
pipe.execute()
if cursor == 0:
break

View File

@@ -0,0 +1,79 @@
"""
Cache key derivation — HMAC-SHA256 over JSON-canonical form.
This is the protocol-critical function. Every Mizan adapter (Python, TypeScript,
future languages) must produce identical output for identical inputs. The
conformance test suite verifies this.
Wire format:
HMAC-SHA256(secret, JSON.stringify({"c": ctx, "p": params, "r": rev}, sort_keys))
- Keys are sorted (JSON canonical form)
- No whitespace (separators=(",", ":"))
- "u" key included only for user-scoped content
- Params are sorted by key name
- All param values are stringified
"""
from __future__ import annotations
import hashlib
import hmac
import json
from typing import Any
def derive_cache_key(
secret: str,
context: str,
params: dict[str, Any],
user_id: str | None = None,
rev: int = 0,
) -> str:
"""
Derive a deterministic HMAC-SHA256 cache key.
Args:
secret: The MIZAN_CACHE_SECRET signing key.
context: Context name (e.g., "user", "products").
params: Query parameters, sorted internally by key.
user_id: User ID for user-scoped content. None for public.
rev: Revision number from @client(rev=N). Defaults to 0.
Returns:
Hex-encoded HMAC-SHA256 digest (64 characters).
"""
# Stringify all param values for cross-language determinism
sorted_params = {k: str(v) for k, v in sorted(params.items())}
key_data: dict[str, Any] = {"c": context, "p": sorted_params, "r": rev}
if user_id is not None:
key_data["u"] = str(user_id)
message = json.dumps(key_data, sort_keys=True, separators=(",", ":"))
return hmac.new(
secret.encode("utf-8"),
message.encode("utf-8"),
hashlib.sha256,
).hexdigest()
def build_index_keys(context: str, params: dict[str, Any]) -> list[str]:
"""
Build reverse index keys for a cache entry.
Returns a list of index keys that this entry should be added to:
- "mizan:idx:{context}" (broad — for purging entire context)
- "mizan:idx:{context}:{key}={value}" (scoped — for per-param purging)
Args:
context: Context name.
params: Query parameters.
Returns:
List of index key strings.
"""
keys = [f"mizan:idx:{context}"]
for k, v in sorted(params.items()):
keys.append(f"mizan:idx:{context}:{k}={str(v)}")
return keys

View File

@@ -23,11 +23,13 @@ from enum import Enum
from functools import wraps from functools import wraps
from typing import TYPE_CHECKING, Any, Callable from typing import TYPE_CHECKING, Any, Callable
from django.http import HttpRequest, JsonResponse from django.http import HttpRequest, HttpResponse, JsonResponse
from django.views.decorators.csrf import csrf_protect from django.views.decorators.csrf import csrf_protect
from pydantic import BaseModel, ValidationError from pydantic import BaseModel, ValidationError
from mizan.cache import get_cache, cache_get, cache_put, cache_purge
from mizan.setup.registry import get_function, get_context_groups from mizan.setup.registry import get_function, get_context_groups
from mizan.setup.settings import get_settings
if TYPE_CHECKING: if TYPE_CHECKING:
pass pass
@@ -661,6 +663,19 @@ def function_call_view(request: HttpRequest) -> JsonResponse:
if invalidate_contexts: if invalidate_contexts:
response["X-Mizan-Invalidate"] = _format_invalidate_header(invalidate_contexts) response["X-Mizan-Invalidate"] = _format_invalidate_header(invalidate_contexts)
# Purge origin-side cache for invalidated contexts
_cache_log = logging.getLogger("mizan.cache")
cache = get_cache()
if cache is not None:
try:
for entry in invalidate_contexts:
if isinstance(entry, str):
cache_purge(cache, entry)
elif isinstance(entry, dict):
cache_purge(cache, entry["context"], entry.get("params"))
except Exception:
_cache_log.warning("Cache purge failed", exc_info=True)
return response return response
@@ -750,8 +765,7 @@ def context_fetch_view(request: HttpRequest, context_name: str) -> JsonResponse:
} }
Headers: Headers:
Cache-Control: public, max-age=0, stale-while-revalidate=300 Cache-Control: public, max-age=0, s-maxage=31536000
Vary: Authorization, Cookie
""" """
if request.method != "GET": if request.method != "GET":
return FunctionError( return FunctionError(
@@ -760,6 +774,28 @@ def context_fetch_view(request: HttpRequest, context_name: str) -> JsonResponse:
).to_response(status=405) ).to_response(status=405)
params = request.GET.dict() params = request.GET.dict()
# Origin-side cache lookup
_cache_log = logging.getLogger("mizan.cache")
cache = get_cache()
cache_settings = get_settings()
user_id = None
if hasattr(request, "user") and hasattr(request.user, "pk") and request.user.pk:
user_id = str(request.user.pk)
if cache is not None and cache_settings.cache_secret:
try:
cached = cache_get(
cache_settings.cache_secret, cache, context_name, params,
user_id=user_id,
)
if cached is not None:
response = HttpResponse(cached, content_type="application/json")
response["Cache-Control"] = "public, max-age=0, s-maxage=31536000"
response["X-Mizan-Cache"] = "HIT"
return response
except Exception:
_cache_log.warning("Cache lookup failed, falling through", exc_info=True)
result = execute_context(request, context_name, params) result = execute_context(request, context_name, params)
if isinstance(result, FunctionError): if isinstance(result, FunctionError):
@@ -780,10 +816,21 @@ def context_fetch_view(request: HttpRequest, context_name: str) -> JsonResponse:
response = JsonResponse(result.data, json_dumps_params={"sort_keys": True}) response = JsonResponse(result.data, json_dumps_params={"sort_keys": True})
# CDN-ready headers # CDN-ready headers
# max-age=0: browser always revalidates (mutations may have invalidated) # max-age=0: browser always revalidates (gets 304 from CDN if unchanged)
# stale-while-revalidate: edge can serve stale while fetching fresh # s-maxage=31536000: CDN caches forever; purge is the freshness mechanism
# Vary: different auth = different cache entry # No Vary header — Cloudflare ignores Vary for personalized content.
response["Cache-Control"] = "public, max-age=0, stale-while-revalidate=300" # User-scoped cache keying will use HMAC-based keys instead.
response["Vary"] = "Authorization, Cookie" response["Cache-Control"] = "public, max-age=0, s-maxage=31536000"
# Store in origin-side cache
if cache is not None and cache_settings.cache_secret:
try:
cache_put(
cache_settings.cache_secret, cache, context_name, params,
response.content, user_id=user_id,
)
response["X-Mizan-Cache"] = "MISS"
except Exception:
_cache_log.warning("Cache store failed", exc_info=True)
return response return response

View File

@@ -391,7 +391,7 @@ def generate_edge_manifest(
registry = get_registry() registry = get_registry()
all_functions = registry.get("functions", {}) all_functions = registry.get("functions", {})
manifest: dict[str, Any] = {"contexts": {}, "mutations": {}} manifest: dict[str, Any] = {"version": 1, "contexts": {}, "mutations": {}}
for ctx_name, fn_names in groups.items(): for ctx_name, fn_names in groups.items():
# Collect params and routes from all functions in this context # Collect params and routes from all functions in this context

View File

@@ -27,6 +27,7 @@ from .registry import (
get_contexts, get_contexts,
get_context_groups, get_context_groups,
get_forms, get_forms,
validate_registry,
clear_registry, clear_registry,
) )
@@ -60,6 +61,7 @@ __all__ = [
"get_contexts", "get_contexts",
"get_context_groups", "get_context_groups",
"get_forms", "get_forms",
"validate_registry",
"clear_registry", "clear_registry",
# Discovery # Discovery
"mizan_clients", "mizan_clients",

View File

@@ -71,6 +71,9 @@ def mizan_clients(apps_root: str, layer: str = "clients") -> None:
visitor = DjangoAppVisitor(layer=layer, apps_root=apps_root) visitor = DjangoAppVisitor(layer=layer, apps_root=apps_root)
visitor.visit(_RegisterServerFunctions()) visitor.visit(_RegisterServerFunctions())
from .registry import validate_registry
validate_registry()
def mizan_module(module_path: str) -> None: def mizan_module(module_path: str) -> None:
""" """

View File

@@ -326,6 +326,46 @@ def get_forms() -> dict[str, list[type["ServerFunction"]]]:
return forms return forms
def validate_registry() -> list[str]:
"""
Validate that all affects targets resolve to known contexts or functions.
Called automatically after discovery. Emits warnings for unresolved targets
(e.g., typos in string-based affects declarations).
Returns a list of warning messages (empty if everything resolves).
"""
import warnings
issues: list[str] = []
groups = get_context_groups()
all_fn_names = set(_functions.keys())
for fn_name, fn_cls in _functions.items():
meta = getattr(fn_cls, "_meta", {})
affects = meta.get("affects")
if not affects:
continue
for target in affects:
target_name = target.get("name", "")
target_type = target.get("type", "")
if target_type == "context" and target_name not in groups:
issues.append(
f"@client function '{fn_name}' declares affects='{target_name}', "
f"but no context named '{target_name}' is registered."
)
elif target_type == "function" and target_name not in all_fn_names:
issues.append(
f"@client function '{fn_name}' targets function '{target_name}', "
f"but no function named '{target_name}' is registered."
)
for msg in issues:
warnings.warn(msg, stacklevel=2)
return issues
def clear_registry() -> None: def clear_registry() -> None:
"""Clear all registrations. Primarily for testing.""" """Clear all registrations. Primarily for testing."""
_functions.clear() _functions.clear()

View File

@@ -17,6 +17,12 @@ class mizanSettings:
# Whether to expose function names in DEBUG mode errors # Whether to expose function names in DEBUG mode errors
debug_expose_names: bool debug_expose_names: bool
# Cache signing secret (required when cache is enabled)
cache_secret: str | None
# Redis URL for cache backend (None = cache disabled)
cache_redis_url: str | None
@lru_cache @lru_cache
def get_settings() -> mizanSettings: def get_settings() -> mizanSettings:
@@ -25,9 +31,13 @@ def get_settings() -> mizanSettings:
Settings: Settings:
mizan_DEBUG_EXPOSE_NAMES: Show function names in errors when DEBUG=True (default: True) mizan_DEBUG_EXPOSE_NAMES: Show function names in errors when DEBUG=True (default: True)
MIZAN_CACHE_SECRET: HMAC signing key for cache keys (default: None)
MIZAN_CACHE_REDIS_URL: Redis connection URL (default: None)
""" """
return mizanSettings( return mizanSettings(
debug_expose_names=getattr(django_settings, "mizan_DEBUG_EXPOSE_NAMES", True), debug_expose_names=getattr(django_settings, "mizan_DEBUG_EXPOSE_NAMES", True),
cache_secret=getattr(django_settings, "MIZAN_CACHE_SECRET", None),
cache_redis_url=getattr(django_settings, "MIZAN_CACHE_REDIS_URL", None),
) )

View File

@@ -1021,9 +1021,7 @@ class ServerDrivenInvalidationTests(TestCase):
# CDN-ready headers # CDN-ready headers
self.assertIn("public", response["Cache-Control"]) self.assertIn("public", response["Cache-Control"])
self.assertIn("stale-while-revalidate", response["Cache-Control"]) self.assertIn("s-maxage", response["Cache-Control"])
self.assertIn("Authorization", response["Vary"])
self.assertIn("Cookie", response["Vary"])
def test_context_error_not_cached(self): def test_context_error_not_cached(self):
"""Context fetch errors must not be cached.""" """Context fetch errors must not be cached."""
@@ -1789,8 +1787,7 @@ class HTTPIntegrationTests(TestCase):
# CDN headers # CDN headers
self.assertIn("public", response["Cache-Control"]) self.assertIn("public", response["Cache-Control"])
self.assertIn("stale-while-revalidate", response["Cache-Control"]) self.assertIn("s-maxage", response["Cache-Control"])
self.assertIn("Authorization", response["Vary"])
def test_context_fetch_string_to_int_coercion(self): def test_context_fetch_string_to_int_coercion(self):
"""Query params arrive as strings. Pydantic must coerce to int.""" """Query params arrive as strings. Pydantic must coerce to int."""
@@ -2151,7 +2148,7 @@ class EdgeCompatibilityTests(TestCase):
cc = response["Cache-Control"] cc = response["Cache-Control"]
self.assertIn("public", cc) self.assertIn("public", cc)
self.assertIn("stale-while-revalidate", cc) self.assertIn("s-maxage", cc)
# Must NOT have no-store or private # Must NOT have no-store or private
self.assertNotIn("no-store", cc) self.assertNotIn("no-store", cc)
self.assertNotIn("private", cc) self.assertNotIn("private", cc)
@@ -2173,16 +2170,6 @@ class EdgeCompatibilityTests(TestCase):
self.assertEqual(response.status_code, 404) self.assertEqual(response.status_code, 404)
self.assertEqual(response["Cache-Control"], "no-store") self.assertEqual(response["Cache-Control"], "no-store")
# ── Vary header correctness ─────────────────────────────────────────────
def test_vary_header_present(self):
"""Context GET includes Vary for auth-dependent caching."""
response = self.client.get("/api/mizan/ctx/user/?user_id=5")
vary = response["Vary"]
self.assertIn("Authorization", vary)
self.assertIn("Cookie", vary)
def test_different_params_different_response(self): def test_different_params_different_response(self):
"""Different query params produce different response bodies (different cache entries).""" """Different query params produce different response bodies (different cache entries)."""
r1 = self.client.get("/api/mizan/ctx/user/?user_id=5") r1 = self.client.get("/api/mizan/ctx/user/?user_id=5")
@@ -2385,9 +2372,9 @@ class EdgeCompatibilityTests(TestCase):
self.assertEqual(header_parts[0], "ctx_0") self.assertEqual(header_parts[0], "ctx_0")
self.assertEqual(header_parts[19], "ctx_19") self.assertEqual(header_parts[19], "ctx_19")
# ── Auth-dependent Vary ───────────────────────────────────────────────── # ── Auth-dependent response differentiation ───────────────────────────
def test_vary_actually_differentiates_by_auth(self): def test_different_auth_produces_different_response(self):
"""Same URL with different auth produces different response bodies.""" """Same URL with different auth produces different response bodies."""
from tests.models import EmailUser from tests.models import EmailUser
@@ -2485,6 +2472,7 @@ class EdgeManifestTests(TestCase):
manifest = generate_edge_manifest() manifest = generate_edge_manifest()
self.assertEqual(manifest["version"], 1)
self.assertIn("contexts", manifest) self.assertIn("contexts", manifest)
self.assertIn("user", manifest["contexts"]) self.assertIn("user", manifest["contexts"])
@@ -2793,3 +2781,250 @@ class PrivateAndRouteTests(TestCase):
manifest = generate_edge_manifest() manifest = generate_edge_manifest()
ctx = manifest["contexts"]["user"] ctx = manifest["contexts"]["user"]
self.assertIn("/profile/<user_id>/", ctx["page_routes"]) self.assertIn("/profile/<user_id>/", ctx["page_routes"])
# ── Cache conformance tests ────────────────────────────────────────────────
class CacheKeyDerivationTests(TestCase):
"""Tests that HMAC cache key derivation is deterministic and correct."""
SECRET = "test-cache-secret"
def test_deterministic_output(self):
"""Same inputs always produce the same key."""
from mizan.cache.keys import derive_cache_key
key1 = derive_cache_key(self.SECRET, "user", {"user_id": "5"})
key2 = derive_cache_key(self.SECRET, "user", {"user_id": "5"})
self.assertEqual(key1, key2)
self.assertEqual(len(key1), 64) # SHA-256 hex digest
def test_param_order_irrelevant(self):
"""Parameter ordering does not affect the key."""
from mizan.cache.keys import derive_cache_key
key1 = derive_cache_key(self.SECRET, "ctx", {"a": "1", "b": "2"})
key2 = derive_cache_key(self.SECRET, "ctx", {"b": "2", "a": "1"})
self.assertEqual(key1, key2)
def test_different_user_ids_different_keys(self):
"""Different user_ids produce different cache keys."""
from mizan.cache.keys import derive_cache_key
key1 = derive_cache_key(self.SECRET, "user", {"user_id": "5"}, user_id="5")
key2 = derive_cache_key(self.SECRET, "user", {"user_id": "5"}, user_id="6")
self.assertNotEqual(key1, key2)
def test_rev_changes_key(self):
"""Different rev values produce different cache keys."""
from mizan.cache.keys import derive_cache_key
key1 = derive_cache_key(self.SECRET, "user", {"user_id": "5"}, rev=0)
key2 = derive_cache_key(self.SECRET, "user", {"user_id": "5"}, rev=1)
self.assertNotEqual(key1, key2)
def test_no_delimiter_collision(self):
"""JSON-canonical form prevents delimiter-free concatenation collisions."""
from mizan.cache.keys import derive_cache_key
# "user" + user_id="12" + params="3" vs "user1" + user_id="2" + params="3"
key1 = derive_cache_key(self.SECRET, "user", {"id": "3"}, user_id="12")
key2 = derive_cache_key(self.SECRET, "user1", {"id": "3"}, user_id="2")
self.assertNotEqual(key1, key2)
def test_public_vs_user_scoped(self):
"""Public (no user_id) and user-scoped produce different keys."""
from mizan.cache.keys import derive_cache_key
public = derive_cache_key(self.SECRET, "products", {"id": "1"})
scoped = derive_cache_key(self.SECRET, "products", {"id": "1"}, user_id="5")
self.assertNotEqual(public, scoped)
class CacheBackendTests(TestCase):
"""Tests for MemoryCache backend operations."""
def setUp(self):
from mizan.cache.backend import MemoryCache
self.cache = MemoryCache()
def test_get_miss(self):
"""Empty cache returns None."""
self.assertIsNone(self.cache.get("nonexistent"))
def test_put_then_get(self):
"""Store and retrieve a value."""
self.cache.put("key1", b'{"data": true}', ["mizan:idx:ctx"])
result = self.cache.get("key1")
self.assertEqual(result, b'{"data": true}')
def test_index_tracking(self):
"""Put adds the key to specified indexes."""
self.cache.put("key1", b"v1", ["mizan:idx:user", "mizan:idx:user:user_id=5"])
self.assertIn("key1", self.cache.get_index("mizan:idx:user"))
self.assertIn("key1", self.cache.get_index("mizan:idx:user:user_id=5"))
def test_delete_many(self):
"""Delete multiple keys at once."""
self.cache.put("k1", b"v1", [])
self.cache.put("k2", b"v2", [])
count = self.cache.delete_many(["k1", "k2"])
self.assertEqual(count, 2)
self.assertIsNone(self.cache.get("k1"))
self.assertIsNone(self.cache.get("k2"))
def test_clear(self):
"""Clear removes everything."""
self.cache.put("k1", b"v1", ["idx1"])
self.cache.clear()
self.assertIsNone(self.cache.get("k1"))
self.assertEqual(self.cache.get_index("idx1"), set())
class CachePurgeTests(TestCase):
"""Tests for cache_purge with scoped and broad invalidation."""
SECRET = "test-cache-secret"
def setUp(self):
from mizan.cache import cache_put, set_cache
from mizan.cache.backend import MemoryCache
self.cache = MemoryCache()
set_cache(self.cache)
# Prime cache with two users in the "user" context
cache_put(self.SECRET, self.cache, "user", {"user_id": "5"}, b'{"name":"user5"}')
cache_put(self.SECRET, self.cache, "user", {"user_id": "6"}, b'{"name":"user6"}')
def tearDown(self):
from mizan.cache import reset_cache
reset_cache()
def test_scoped_purge(self):
"""Purging user;user_id=5 removes only that entry."""
from mizan.cache import cache_purge, cache_get
count = cache_purge(self.cache, "user", {"user_id": "5"})
self.assertEqual(count, 1)
# user_id=5 is gone
self.assertIsNone(cache_get(self.SECRET, self.cache, "user", {"user_id": "5"}))
# user_id=6 survives
self.assertIsNotNone(cache_get(self.SECRET, self.cache, "user", {"user_id": "6"}))
def test_broad_purge(self):
"""Purging context with no params removes all entries."""
from mizan.cache import cache_purge, cache_get
count = cache_purge(self.cache, "user")
self.assertEqual(count, 2)
self.assertIsNone(cache_get(self.SECRET, self.cache, "user", {"user_id": "5"}))
self.assertIsNone(cache_get(self.SECRET, self.cache, "user", {"user_id": "6"}))
class CacheIntegrationTests(TestCase):
"""Tests for cache integration with context_fetch_view and function_call_view."""
def setUp(self):
clear_registry()
self.factory = RequestFactory()
from mizan.cache import set_cache
from mizan.cache.backend import MemoryCache
from mizan.setup.settings import clear_settings_cache
self.cache = MemoryCache()
set_cache(self.cache)
clear_settings_cache()
UserCtx = ReactContext("user")
@client(context=UserCtx)
def user_profile(request: HttpRequest, user_id: int) -> dict:
return {"name": f"user_{user_id}"}
@client(affects=UserCtx)
def update_profile(request: HttpRequest, user_id: int, name: str) -> dict:
return {"ok": True}
register(user_profile, "user_profile")
register(update_profile, "update_profile")
def tearDown(self):
clear_registry()
from mizan.cache import reset_cache
from mizan.setup.settings import clear_settings_cache
reset_cache()
clear_settings_cache()
def _fetch_context(self, params="user_id=5"):
from django.test import override_settings
with override_settings(
MIZAN_CACHE_SECRET="test-secret",
MIZAN_CACHE_REDIS_URL="dummy",
):
from mizan.setup.settings import clear_settings_cache
clear_settings_cache()
response = self.client.get(f"/api/mizan/ctx/user/?{params}")
return response
def _call_mutation(self, fn_name, args):
from django.test import override_settings
with override_settings(
MIZAN_CACHE_SECRET="test-secret",
MIZAN_CACHE_REDIS_URL="dummy",
):
from mizan.setup.settings import clear_settings_cache
clear_settings_cache()
response = self.client.post(
"/api/mizan/call/",
data=json.dumps({"fn": fn_name, "args": args}),
content_type="application/json",
)
return response
def test_first_fetch_is_miss(self):
"""First context fetch executes functions (cache miss)."""
response = self._fetch_context()
self.assertEqual(response.status_code, 200)
data = response.json()
self.assertEqual(data["user_profile"]["result"]["name"], "user_5")
def test_second_fetch_is_hit(self):
"""Second identical fetch returns cached response."""
r1 = self._fetch_context()
r2 = self._fetch_context()
self.assertEqual(r1.content, r2.content)
self.assertEqual(r2.get("X-Mizan-Cache"), "HIT")
def test_mutation_invalidates_cache(self):
"""Mutation purges the context cache; next fetch is a miss."""
# Prime cache
r1 = self._fetch_context()
self.assertIn(r1.get("X-Mizan-Cache"), ("MISS", None))
# Mutate
self._call_mutation("update_profile", {"user_id": 5, "name": "new"})
# Cache should be purged — next fetch is a miss
r2 = self._fetch_context()
# Should re-execute, not serve stale
self.assertEqual(r2.json()["user_profile"]["result"]["name"], "user_5")
def test_scoped_invalidation_preserves_other_entries(self):
"""Mutating user 5 doesn't invalidate user 6's cache."""
# Prime both users
self._fetch_context("user_id=5")
self._fetch_context("user_id=6")
# Mutate only user 5
self._call_mutation("update_profile", {"user_id": 5, "name": "new"})
# User 6 should still be cached
r6 = self._fetch_context("user_id=6")
self.assertEqual(r6.get("X-Mizan-Cache"), "HIT")

View File

@@ -124,7 +124,7 @@ export async function mizanCall(
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
credentials: 'same-origin', credentials: 'same-origin',
body: JSON.stringify({ function: functionName, args }), body: JSON.stringify({ fn: functionName, args }),
}) })
if (!res.ok) throw new MizanError(res.status, await res.text()) if (!res.ok) throw new MizanError(res.status, await res.text())

View File

@@ -72,8 +72,7 @@ export async function handleContextFetch(
body: results, body: results,
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'Cache-Control': 'public, max-age=0, stale-while-revalidate=300', 'Cache-Control': 'public, max-age=0, s-maxage=31536000',
'Vary': 'Authorization, Cookie',
}, },
} }
} }

View File

@@ -8,12 +8,14 @@
import type { EdgeManifest } from './types' import type { EdgeManifest } from './types'
import { getAllFunctions, getContextGroups, getContextParamNames } from './registry' import { getAllFunctions, getContextGroups, getContextParamNames } from './registry'
// Both camelCase and snake_case forms included for cross-language matching.
// Wire format is snake_case (protocol rule); camelCase is the TS-local convention.
const USER_SCOPED_PARAMS = new Set(['userId', 'user', 'ownerId', 'accountId', 'user_id', 'owner_id', 'account_id']) const USER_SCOPED_PARAMS = new Set(['userId', 'user', 'ownerId', 'accountId', 'user_id', 'owner_id', 'account_id'])
export function generateManifest(baseUrl = '/api/mizan'): EdgeManifest { export function generateManifest(baseUrl = '/api/mizan'): EdgeManifest {
const groups = getContextGroups() const groups = getContextGroups()
const allFunctions = getAllFunctions() const allFunctions = getAllFunctions()
const manifest: EdgeManifest = { contexts: {}, mutations: {} } const manifest: EdgeManifest = { version: 1, contexts: {}, mutations: {} }
// Contexts // Contexts
for (const [ctxName, fnNames] of Object.entries(groups)) { for (const [ctxName, fnNames] of Object.entries(groups)) {

View File

@@ -56,6 +56,7 @@ export interface ManifestMutation {
} }
export interface EdgeManifest { export interface EdgeManifest {
version: number
contexts: Record<string, ManifestContext> contexts: Record<string, ManifestContext>
mutations: Record<string, ManifestMutation> mutations: Record<string, ManifestMutation>
} }

View File

@@ -55,7 +55,7 @@ describe('Edge Compatibility', () => {
test('context GET is cacheable', async () => { test('context GET is cacheable', async () => {
const r = await handleContextFetch('user', { userId: '5' }) const r = await handleContextFetch('user', { userId: '5' })
expect(r.headers['Cache-Control']).toContain('public') expect(r.headers['Cache-Control']).toContain('public')
expect(r.headers['Cache-Control']).toContain('stale-while-revalidate') expect(r.headers['Cache-Control']).toContain('s-maxage')
expect(r.headers['Cache-Control']).not.toContain('no-store') expect(r.headers['Cache-Control']).not.toContain('no-store')
}) })
@@ -70,14 +70,6 @@ describe('Edge Compatibility', () => {
expect(r.headers['Cache-Control']).toBe('no-store') expect(r.headers['Cache-Control']).toBe('no-store')
}) })
// ── Vary header ────────────────────────────────────────────────────
test('Vary header present on context GET', async () => {
const r = await handleContextFetch('user', { userId: '5' })
expect(r.headers['Vary']).toContain('Authorization')
expect(r.headers['Vary']).toContain('Cookie')
})
// ── X-Mizan-Invalidate header ────────────────────────────────────── // ── X-Mizan-Invalidate header ──────────────────────────────────────
test('mutation response includes invalidation header', async () => { test('mutation response includes invalidation header', async () => {
@@ -195,6 +187,7 @@ describe('Manifest', () => {
test('manifest matches expected structure', () => { test('manifest matches expected structure', () => {
const m = generateManifest() const m = generateManifest()
expect(m.version).toBe(1)
expect(m.contexts.user).toBeDefined() expect(m.contexts.user).toBeDefined()
expect(m.contexts.user.endpoints).toEqual(['/api/mizan/ctx/user/']) expect(m.contexts.user.endpoints).toEqual(['/api/mizan/ctx/user/'])
expect(m.contexts.user.params).toContain('userId') expect(m.contexts.user.params).toContain('userId')