Add rev and cache parameters to @client decorator

rev=N: bumped by developer when function logic changes. Becomes part of
the HMAC cache key — old cache entries are unreachable without purge.
Effective rev for a context is max(rev) across all functions in it.

cache=int|False|True: TTL escape hatch for unobservable mutations.
cache=60 emits s-maxage=60. cache=False emits no-store. Default (True)
emits s-maxage=31536000 (forever, purge on mutation).
Effective cache for a context is min(TTL) across functions, with False
taking precedence.

Both parameters flow through: decorator → meta → manifest → cache key
and Cache-Control headers. Implemented in both Python and TypeScript
with 13 Python tests and 4 TypeScript tests.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-07 00:20:32 -04:00
parent 7daec1c2e2
commit a2388b3ab2
9 changed files with 370 additions and 19 deletions

View File

@@ -775,22 +775,51 @@ def context_fetch_view(request: HttpRequest, context_name: str) -> JsonResponse:
params = request.GET.dict()
# Origin-side cache lookup
# Resolve effective rev and cache policy across all functions in this context
_cache_log = logging.getLogger("mizan.cache")
cache = get_cache()
groups = get_context_groups()
fn_names = groups.get(context_name, [])
effective_rev = 0
effective_cache: int | bool = True # True=forever, False=no-store, int=TTL
for fn_name in fn_names:
fn_cls = get_function(fn_name)
if fn_cls:
meta = getattr(fn_cls, "_meta", {})
fn_rev = meta.get("rev", 0)
effective_rev = max(effective_rev, fn_rev)
fn_cache = meta.get("cache", True)
if fn_cache is False:
effective_cache = False
break
elif isinstance(fn_cache, int):
if effective_cache is True:
effective_cache = fn_cache
else:
effective_cache = min(effective_cache, fn_cache)
# Origin-side cache lookup (skip if cache=False)
cache_backend = 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:
use_cache = (
cache_backend is not None
and cache_settings.cache_secret
and effective_cache is not False
)
if use_cache:
try:
cached = cache_get(
cache_settings.cache_secret, cache, context_name, params,
user_id=user_id,
cache_settings.cache_secret, cache_backend, context_name, params,
user_id=user_id, rev=effective_rev,
)
if cached is not None:
response = HttpResponse(cached, content_type="application/json")
response["Cache-Control"] = "public, max-age=0, s-maxage=31536000"
if isinstance(effective_cache, int):
response["Cache-Control"] = f"public, max-age=0, s-maxage={effective_cache}"
else:
response["Cache-Control"] = "public, max-age=0, s-maxage=31536000"
response["X-Mizan-Cache"] = "HIT"
return response
except Exception:
@@ -815,19 +844,20 @@ def context_fetch_view(request: HttpRequest, context_name: str) -> JsonResponse:
# Deterministic JSON (sorted keys) for consistent cache keys
response = JsonResponse(result.data, json_dumps_params={"sort_keys": True})
# CDN-ready headers
# max-age=0: browser always revalidates (gets 304 from CDN if unchanged)
# s-maxage=31536000: CDN caches forever; purge is the freshness mechanism
# No Vary header — Cloudflare ignores Vary for personalized content.
# User-scoped cache keying will use HMAC-based keys instead.
response["Cache-Control"] = "public, max-age=0, s-maxage=31536000"
# CDN-ready headers based on effective cache policy
if effective_cache is False:
response["Cache-Control"] = "no-store"
elif isinstance(effective_cache, int):
response["Cache-Control"] = f"public, max-age=0, s-maxage={effective_cache}"
else:
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:
# Store in origin-side cache (skip if cache=False)
if use_cache:
try:
cache_put(
cache_settings.cache_secret, cache, context_name, params,
response.content, user_id=user_id,
cache_settings.cache_secret, cache_backend, context_name, params,
response.content, user_id=user_id, rev=effective_rev,
)
response["X-Mizan-Cache"] = "MISS"
except Exception:

View File

@@ -261,6 +261,8 @@ def client(
methods: list[str] | None = None,
websocket: bool = False,
auth: bool | str | Callable[[Any], bool] | None = None,
rev: int = 0,
cache: int | bool = True,
) -> type[ServerFunction] | Callable[[Callable], type[ServerFunction]]:
"""
Register a function as a server function.
@@ -336,7 +338,7 @@ def client(
return _create_server_function(
fn, context=resolved_context, affects=affects,
private=private, route=route, methods=methods,
websocket=websocket, auth=auth,
websocket=websocket, auth=auth, rev=rev, cache=cache,
)
# Support both @client and @client(...)
@@ -344,7 +346,7 @@ def client(
return _create_server_function(
fn, context=resolved_context, affects=affects,
private=private, route=route, methods=methods,
websocket=websocket, auth=auth,
websocket=websocket, auth=auth, rev=rev, cache=cache,
)
return decorator
@@ -387,6 +389,8 @@ def _create_server_function(
methods: list[str] | None = None,
websocket: bool = False,
auth: bool | str | None = None,
rev: int = 0,
cache: int | bool = True,
) -> type[ServerFunction]:
"""Internal helper that creates a ServerFunction from a decorated function."""
from pydantic import create_model
@@ -523,6 +527,15 @@ def _create_server_function(
else:
meta["auth"] = auth
# Revision: bumped by developer when function logic changes.
# Part of the HMAC cache key — old entries become unreachable orphans.
if rev != 0:
meta["rev"] = rev
# Cache policy: True=forever (default), False=no-store, int=TTL seconds
if cache is not True:
meta["cache"] = cache
if meta:
FunctionWrapper._meta = {**FunctionWrapper._meta, **meta}

View File

@@ -428,6 +428,12 @@ def generate_edge_manifest(
fn_entry["methods"] = meta.get("methods", ["GET"])
page_routes.append(fn_route)
# Cache protocol metadata
if "rev" in meta:
fn_entry["rev"] = meta["rev"]
if "cache" in meta:
fn_entry["cache"] = meta["cache"]
functions_meta.append(fn_entry)
sorted_params = sorted(param_names)

View File

@@ -3028,3 +3028,227 @@ class CacheIntegrationTests(TestCase):
# User 6 should still be cached
r6 = self._fetch_context("user_id=6")
self.assertEqual(r6.get("X-Mizan-Cache"), "HIT")
# ── Rev and cache parameter tests ──────────────────────────────────────────
class RevParameterTests(TestCase):
"""Tests for the @client(rev=N) parameter."""
def setUp(self):
clear_registry()
def tearDown(self):
clear_registry()
def test_rev_stored_in_meta(self):
"""@client(rev=2) stores rev in function metadata."""
Ctx = ReactContext("data")
@client(context=Ctx, rev=2)
def my_fn(request: HttpRequest) -> dict:
return {}
register(my_fn, "my_fn")
meta = getattr(get_function("my_fn"), "_meta", {})
self.assertEqual(meta["rev"], 2)
def test_rev_default_not_in_meta(self):
"""@client with default rev=0 does not store rev in meta."""
Ctx = ReactContext("data")
@client(context=Ctx)
def my_fn(request: HttpRequest) -> dict:
return {}
register(my_fn, "my_fn")
meta = getattr(get_function("my_fn"), "_meta", {})
self.assertNotIn("rev", meta)
def test_rev_changes_cache_key(self):
"""Different rev values produce different HMAC cache keys."""
from mizan.cache.keys import derive_cache_key
key_v0 = derive_cache_key("secret", "ctx", {"id": "1"}, rev=0)
key_v1 = derive_cache_key("secret", "ctx", {"id": "1"}, rev=1)
self.assertNotEqual(key_v0, key_v1)
def test_rev_in_manifest(self):
"""Manifest includes rev on functions that set it."""
from mizan.export import generate_edge_manifest
Ctx = ReactContext("data")
@client(context=Ctx, rev=3)
def versioned_fn(request: HttpRequest, item_id: int) -> dict:
return {}
register(versioned_fn, "versioned_fn")
manifest = generate_edge_manifest()
fn_entry = manifest["contexts"]["data"]["functions"][0]
self.assertEqual(fn_entry["rev"], 3)
def test_rev_not_in_manifest_when_default(self):
"""Manifest omits rev when it's the default (0)."""
from mizan.export import generate_edge_manifest
Ctx = ReactContext("data")
@client(context=Ctx)
def default_fn(request: HttpRequest) -> dict:
return {}
register(default_fn, "default_fn")
manifest = generate_edge_manifest()
fn_entry = manifest["contexts"]["data"]["functions"][0]
self.assertNotIn("rev", fn_entry)
class CacheParameterTests(TestCase):
"""Tests for the @client(cache=...) parameter."""
def setUp(self):
clear_registry()
def tearDown(self):
clear_registry()
def test_cache_int_stored_in_meta(self):
"""@client(cache=60) stores cache TTL in meta."""
Ctx = ReactContext("data")
@client(context=Ctx, cache=60)
def my_fn(request: HttpRequest) -> dict:
return {}
register(my_fn, "my_fn")
meta = getattr(get_function("my_fn"), "_meta", {})
self.assertEqual(meta["cache"], 60)
def test_cache_false_stored_in_meta(self):
"""@client(cache=False) stores False in meta."""
Ctx = ReactContext("data")
@client(context=Ctx, cache=False)
def my_fn(request: HttpRequest) -> dict:
return {}
register(my_fn, "my_fn")
meta = getattr(get_function("my_fn"), "_meta", {})
self.assertIs(meta["cache"], False)
def test_cache_default_not_in_meta(self):
"""Default cache=True is not stored in meta."""
Ctx = ReactContext("data")
@client(context=Ctx)
def my_fn(request: HttpRequest) -> dict:
return {}
register(my_fn, "my_fn")
meta = getattr(get_function("my_fn"), "_meta", {})
self.assertNotIn("cache", meta)
def test_cache_in_manifest(self):
"""Manifest includes cache TTL on functions that set it."""
from mizan.export import generate_edge_manifest
Ctx = ReactContext("data")
@client(context=Ctx, cache=120)
def ttl_fn(request: HttpRequest) -> dict:
return {}
register(ttl_fn, "ttl_fn")
manifest = generate_edge_manifest()
fn_entry = manifest["contexts"]["data"]["functions"][0]
self.assertEqual(fn_entry["cache"], 120)
class CachePolicyIntegrationTests(TestCase):
"""Tests for effective cache policy resolution in context_fetch_view."""
def setUp(self):
clear_registry()
def tearDown(self):
clear_registry()
def test_cache_int_sets_smaxage(self):
"""Context with cache=60 emits s-maxage=60."""
Ctx = ReactContext("trending")
@client(context=Ctx, cache=60)
def trending(request: HttpRequest) -> dict:
return {"items": []}
register(trending, "trending")
response = self.client.get("/api/mizan/ctx/trending/")
self.assertIn("s-maxage=60", response["Cache-Control"])
self.assertNotIn("31536000", response["Cache-Control"])
def test_cache_false_sets_nostore(self):
"""Context with cache=False emits no-store."""
Ctx = ReactContext("random")
@client(context=Ctx, cache=False)
def random_fn(request: HttpRequest) -> dict:
return {"value": 42}
register(random_fn, "random_fn")
response = self.client.get("/api/mizan/ctx/random/")
self.assertEqual(response["Cache-Control"], "no-store")
def test_effective_cache_is_minimum(self):
"""Context with mixed TTLs uses the minimum."""
Ctx = ReactContext("mixed")
@client(context=Ctx, cache=300)
def slow_fn(request: HttpRequest) -> dict:
return {"slow": True}
@client(context=Ctx, cache=60)
def fast_fn(request: HttpRequest) -> dict:
return {"fast": True}
register(slow_fn, "slow_fn")
register(fast_fn, "fast_fn")
response = self.client.get("/api/mizan/ctx/mixed/")
self.assertIn("s-maxage=60", response["Cache-Control"])
def test_effective_rev_is_maximum(self):
"""Context with mixed revs uses the maximum for cache key."""
from mizan.cache.keys import derive_cache_key
from mizan.cache import set_cache, reset_cache
from mizan.cache.backend import MemoryCache
from mizan.setup.settings import clear_settings_cache
from django.test import override_settings
Ctx = ReactContext("versioned")
@client(context=Ctx, rev=0)
def old_fn(request: HttpRequest, item_id: int) -> dict:
return {"old": True}
@client(context=Ctx, rev=2)
def new_fn(request: HttpRequest, item_id: int) -> dict:
return {"new": True}
register(old_fn, "old_fn")
register(new_fn, "new_fn")
mem_cache = MemoryCache()
set_cache(mem_cache)
with override_settings(MIZAN_CACHE_SECRET="test", MIZAN_CACHE_REDIS_URL="dummy"):
clear_settings_cache()
r1 = self.client.get("/api/mizan/ctx/versioned/?item_id=1")
self.assertEqual(r1.status_code, 200)
# The cache key should use rev=2 (max)
expected_key = derive_cache_key("test", "versioned", {"item_id": "1"}, rev=2)
self.assertIn(expected_key, mem_cache._store)
reset_cache()
clear_settings_cache()

View File

@@ -102,6 +102,8 @@ export function client(optionsOrFn: ClientOptions | ClientOptions, fn?: Function
route: options.route,
methods: options.methods,
auth: options.auth,
rev: options.rev,
cache: options.cache,
}
register(entry)
@@ -132,6 +134,8 @@ export function client(optionsOrFn: ClientOptions | ClientOptions, fn?: Function
route: options.route,
methods: options.methods,
auth: options.auth,
rev: options.rev,
cache: options.cache,
}
register(entry)

View File

@@ -67,12 +67,34 @@ export async function handleContextFetch(
}
}
// Resolve effective cache policy (minimum TTL across all functions)
let effectiveCache: number | boolean = true
for (const fnName of fnNames) {
const entry = getFunction(fnName)
if (!entry) continue
if (entry.cache === false) { effectiveCache = false; break }
if (typeof entry.cache === 'number') {
effectiveCache = effectiveCache === true
? entry.cache
: Math.min(effectiveCache as number, entry.cache)
}
}
let cacheControl: string
if (effectiveCache === false) {
cacheControl = 'no-store'
} else if (typeof effectiveCache === 'number') {
cacheControl = `public, max-age=0, s-maxage=${effectiveCache}`
} else {
cacheControl = 'public, max-age=0, s-maxage=31536000'
}
return {
status: 200,
body: results,
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'public, max-age=0, s-maxage=31536000',
'Cache-Control': cacheControl,
},
}
}

View File

@@ -35,6 +35,8 @@ export function generateManifest(baseUrl = '/api/mizan'): EdgeManifest {
fnEntry.methods = entry.methods || ['GET']
pageRoutes.push(entry.route)
}
if (entry.rev !== undefined && entry.rev !== 0) fnEntry.rev = entry.rev
if (entry.cache !== undefined && entry.cache !== true) fnEntry.cache = entry.cache
functions.push(fnEntry)
}

View File

@@ -17,6 +17,8 @@ export interface ClientOptions {
route?: string
methods?: string[]
auth?: boolean
rev?: number
cache?: number | false
}
export interface ParamDef {
@@ -36,6 +38,8 @@ export interface RegistryEntry {
route?: string
methods?: string[]
auth?: boolean
rev?: number
cache?: number | false
}
export interface ManifestContext {

View File

@@ -228,4 +228,50 @@ describe('Manifest', () => {
expect(m.mutations.stripeWebhook.route).toBe('/webhooks/stripe/')
expect(m.mutations.stripeWebhook.methods).toEqual(['POST'])
})
test('rev appears in manifest', () => {
clearRegistry()
const Ctx = new ReactContext('data')
client({ context: Ctx, rev: 3 }, async function versionedFn(itemId: number) {
return { value: itemId }
})
const m = generateManifest()
const fn = m.contexts.data.functions[0]
expect(fn.rev).toBe(3)
})
test('cache TTL appears in manifest', () => {
clearRegistry()
const Ctx = new ReactContext('trending')
client({ context: Ctx, cache: 60 }, async function trendingFn() {
return { items: [] }
})
const m = generateManifest()
const fn = m.contexts.trending.functions[0]
expect(fn.cache).toBe(60)
})
test('cache=60 sets s-maxage=60', async () => {
clearRegistry()
const Ctx = new ReactContext('live')
client({ context: Ctx, cache: 60 }, async function liveFn() {
return { score: 42 }
})
const r = await handleContextFetch('live', {})
expect(r.headers['Cache-Control']).toBe('public, max-age=0, s-maxage=60')
})
test('cache=false sets no-store', async () => {
clearRegistry()
const Ctx = new ReactContext('random')
client({ context: Ctx, cache: false }, async function randomFn() {
return { value: Math.random() }
})
const r = await handleContextFetch('random', {})
expect(r.headers['Cache-Control']).toBe('no-store')
})
})