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:
@@ -775,21 +775,50 @@ 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")
|
||||
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
|
||||
@@ -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.
|
||||
# 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:
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user