Caching
Citry can cache and replay complete rendered subtrees. Use one of two scopes:
Component.Cachecaches every call to one component class.<c-cache>caches one explicitly named region inside a template.
Both use the cache backend configured on your Citry instance. That same store also holds derived dependency scripts and, when configured, server-held Events State. A hit preserves correct client behavior while giving each replayed component a fresh identity for the current render.
Caching is an optimization. A miss always renders normally, and cached values must never be the authoritative home of application state.
Cache a component
Add a nested Cache class and set enabled = True:
from citry import Citry, Component
app = Citry()
class ProductCard(Component):
citry = app
class Cache:
enabled = True
ttl = 300
version = 1
class Kwargs:
product_id: int
template = """
<article>{{ product_name }}</article>
"""
def template_data(self, kwargs, slots):
product = load_product(kwargs.product_id)
return {"product_name": product.name}
The first ProductCard(product_id=42) renders and stores its subtree. Later calls with the same effective typed kwargs replay it until it expires or is invalidated. The default variation includes every effective Kwargs value after defaults, factories, input hooks, and final type validation.
ttl is seconds. A positive exact integer or float expires the entry, None means no expiry, and zero bypasses lookup and storage. enabled = False also bypasses caching. A hit does not refresh the original expiry.
Custom variation
Use Cache.vary() when the complete typed kwargs contain objects that should be reduced to stable identifiers, or when only selected values affect output:
class ProductCard(Component):
citry = app
class Cache:
enabled = True
def vary(self, kwargs, slots):
return {
"product_id": kwargs["product"].id,
"locale": kwargs["locale"],
}
class Kwargs:
product: object
locale: str
template = """
<article>{{ product_name }}</article>
"""
kwargs and slots are read-only snapshots. The return value must use Citry's canonical variation types: None, exact booleans, integers, finite floats, strings, bytes, and exact built-in list, tuple, or dict containers composed from those values. Dictionary keys must be exact strings. Values may be at most 32 containers deep, 10,000 nodes, and 64 KiB in the canonical key format.
A custom method is a correctness promise. Include every input that can change the rendered subtree. Convert domain objects to stable IDs; do not use an arbitrary object's str() or repr().
Components with Slots
The default component variation does not guess what a Slot will render. If an effective supplied Slot, typed Slot default, factory, or input hook produces content, caching raises CacheKeyError unless the component defines a custom Cache.vary().
Leave personalized or context-sensitive slotted components uncached unless you can describe every output distinction explicitly:
from citry import Component, SlotInput
class PersonalizedPanel(Component):
citry = app
class Cache:
enabled = False
class Slots:
body: SlotInput | None = None
template = """
<section><c-slot name="body" /></section>
"""
An optional Slot whose effective typed value is None is safe with default variation. Fallback markup written inside <c-slot> belongs to the component template and does not count as a caller-supplied Slot source.
Cache a template region
<c-cache> is transparent: it adds no HTML wrapper. Give it a stable semantic key, then vary on every caller-dependent value used by the body:
class AccountPage(Component):
citry = app
template = """
<c-cache
key="account-menu"
c-vary="[current_user.id, locale]"
c-ttl="300"
>
<c-account-menu c-user="current_user" c-locale="locale" />
</c-cache>
"""
This creates a separate fragment for each user and locale. The body itself is not inspected or included in the key. If the body also depends on tenant, permissions, timezone, feature flags, provide/inject data, or another ambient value, include that value in c-vary too.
The controls are:
key: required exact non-empty string.vary: one canonical value, defaulting to an empty tuple.ttl: seconds,None, or zero; omitted uses the engine Cache default.version: exact integer or non-empty string, defaulting to1.enabled: exact boolean, defaulting toTrue.
Literal HTML attributes are strings. Write c-ttl="300" and c-enabled="False" for typed values; ttl="300" and enabled="false" are invalid strings.
On a hit, Citry skips the fragment body, its child components, data methods, render hooks, and Slot rendering. For nested caches, a live outer hit therefore suppresses every inner lookup. Keep the outer TTL no longer than freshness requirements for anything inside it.
Configure the backend
Pass cache= when you construct Citry. It accepts a backend object, an import string, or None:
from citry import Citry, InMemoryCache
# Default: one fresh in-process cache for this Citry instance.
app = Citry()
assert isinstance(app.cache, InMemoryCache)
# Bound the number of values in the in-process store.
backend = InMemoryCache(max_entries=1000)
app = Citry(cache=backend)
assert app.cache is backend
# Or name a class that can be constructed without arguments.
app = Citry(cache="citry.cache.InMemoryCache")
The original setting remains on app.settings.cache; the live backend is app.cache. A backend implements the CitryCache protocol:
get(key)returns a stored string orNone.set(key, value, ttl=None)stores a string.delete(key)removes one key.has(key)reports whether a key is present.
Citry stores JSON strings and never asks a backend to pickle live Python objects. Missing protocol methods fail at Citry construction.
Share render entries across workers
InMemoryCache is thread-safe but process-local. Use a shared backend for multiple workers, then configure both a namespace and a deployment generation:
import os
import redis
from citry import Citry
from citry.contrib.caches import RedisCache
backend = RedisCache(redis.Redis(host="cache.internal"), prefix="myapp:")
app = Citry(
cache=backend,
extensions_defaults={
"cache": {
"namespace": "storefront-production",
"generation": os.environ["RELEASE_SHA"],
"ttl": 300,
"max_entry_bytes": 1_000_000,
}
},
)
Cross-engine render hits are enabled only when both namespace and generation are non-None. A namespace without a generation remains local to one Citry instance. Change the generation whenever code, templates, extensions, helpers, or global configuration can change rendered output.
Other shared adapters are available for one host or a Django application:
import diskcache
from citry.contrib.caches import DiskCache
disk_backend = DiskCache(diskcache.Cache("/var/cache/citry"))
from django.core.cache import caches
from citry.contrib.django import DjangoCache
django_backend = DjangoCache(caches["default"])
The backend's capacity covers every value in the shared store, including render artifacts, dependency scripts, and server-held Events State. InMemoryCache(max_entries=...) limits that total entry count. max_entry_bytes is different: it caps one render artifact. An oversized artifact still renders successfully but is not stored.
Invalidate cached output
Use a component or fragment version when one output family changes. A version bump selects a new family immediately without finding every old variation:
class ProductCard(Component):
citry = app
class Cache:
enabled = True
version = "product-card-v2"
<c-cache key="category-nav" version="nav-v3">
...
</c-cache>
For one exact variation, build its physical key and delete it:
from citry.ext.cache import component_cache_key, fragment_cache_key
component_key = component_cache_key(
ProductCard,
vary={"product_id": 42},
version="product-card-v2",
)
app.cache.delete(component_key)
fragment_key = fragment_cache_key(
app,
"account-menu",
vary=[user_id, locale],
)
app.cache.delete(fragment_key)
component_cache_key() accepts the already computed semantic variation. It does not instantiate the component or call Cache.vary(). Citry intentionally has no distributed index for deleting every variant.
Citry.clear(), component removal, and template/file resets advance local invalidation state. Citry.clear() also clears a backend that exposes a callable clear(), such as InMemoryCache; the shared contrib adapters leave store-wide clearing to their clients. Coordinate a generation change across workers for deployment-wide invalidation.
Security and correctness checklist
Before enabling output caching:
- Include every caller-dependent dimension in the variation: user, tenant, permissions, locale, timezone, flags, experiments, CSRF values, CSP nonces, template globals, and provide/inject data are common examples.
- Cache only output reusable by every caller that shares a key.
- Treat the backend as trusted application infrastructure. Someone with write access can inject HTML that Citry will trust on replay.
- Use stable scalar identifiers instead of
str()orrepr()of arbitrary objects. - Change the deployment generation for every output-affecting deployment or configuration change.
- Use component or fragment versions for targeted semantic invalidation.
- Remember that a longer-lived outer hit suppresses all inner cache work.
- Apply suitable access controls and retention to the backend. Artifacts can contain rendered private data, protected Events state, and dependency data.
Physical backend keys and ordinary diagnostics contain opaque digests rather than raw variation values or authored fragment names. That protects logs from accidental disclosure, but it does not make cached artifacts public data.
Inspect Cache policy metadata
Component introspection includes Cache metadata only when explicitly requested:
catalog = app.inspect_components(include_extensions=("cache",))
payload = catalog.to_dict()
assert payload["extension_versions"]["cache"] == 1
Version 1 publishes this exact component-policy shape:
{
"enabled": true,
"ttl": 60.0,
"version": {"kind": "string", "value": "card-v2"},
"variation": "custom",
"default_variation_slot_source": "not-applicable"
}
Integer versions use tagged hexadecimal text, for example {"kind": "integer", "value": "0x1"}. This preserves integers larger than JSON's portable safe range and keeps integer 1 distinct from string "1".
For default variation, default_variation_slot_source is none when the component has a closed, empty Slots schema, or possible when future supplied fills, declared fields, opaque schemas, factories, or input hooks may produce content. For custom variation it is not-applicable. This is static policy metadata, not a prediction about one future call.
Inspection never calls Cache.vary(), executes a default factory, loads an asset, or renders. It also omits backend namespace, generation, capacity, keys, and runtime variation values. The <c-cache> built-in publishes no misleading Component.Cache entry; its core built-in metadata and Kwargs schema describe the fragment surface.
Related pages
- Performance for
Const, which reuses stable pieces inside ordinary renders rather than caching a whole subtree. - Extensions for extension lifecycle and metadata.
- Security for template and Events trust boundaries.