Guide to Caching and Cache Invalidation
The C3 Agentic AI Platform caches frequently accessed Type data in memory for low-latency access. Each cacheable Type gets its own cache, keyed per application (and, for multi-tenant apps, per tenant/tag). Because an application runs across many nodes, a value cached on one node must be invalidated when the underlying data changes on another node. This topic explains what the platform caches, how to make a Type cacheable and read its instances, how cross-node invalidation keeps caches consistent, and how to enable and configure remote invalidation.
Concepts
Cache: The in-memory data structure that holds a Type's instances for low-latency access. One cache exists per cacheable Type, isolated per application and, for multi-tenant apps, per tenant/tag. See the Cache Type.
Cached: The base Type that makes a Type's instances cacheable. A Type opts in by mixing Cached; its instances are then keyed by a cache key and served from the Type's cache. See the Cached Type and Working with Cached Types.
Cache key: The identifier under which an instance is stored in its Type's cache — the id for Persistable / Identified Types, the name for Named Types, the Type name for singletons, and the config key for Config.
@cache annotation: The Type-level Ann.Cache annotation that shapes cache behavior — size (maxSize), full loading (all), expiry (ttl / ttr), and priming (refresh).
Cache invalidation: Evicting a stale entry (or clearing a cache) so the next access re-produces a fresh value. On a single node this is local; in a cluster the eviction must be broadcast to the other nodes that hold the same entry.
Cache.Invalidator: The dispatcher and receiver of cache invalidation messages across nodes. See the Cache.Invalidator Type. Its concrete transports are HTTP (direct connections) and DB (via the CacheQueue).
CacheMessage: The message describing a single invalidation — which Type, key, cache, action, and level. See the CacheMessage Type.
Making a Type cacheable
The following sub-types of Cached are cached implicitly, with no extra configuration:
SingletonandDefaultInstance(accessed viainst())Config(accessed viaforConfigKey(key))
A persistable entity type becomes cached by mixing Cached. Instances are then cached by their id, and the cache is refreshed automatically when the data is created, updated, or deleted (with a small delay between the write and the refresh):
entity type MyCachedData mixes Cached {
data: any
}Any other sub-type of Cached (one that is not Persistable, a Singleton, or a Config) must implement the produce(key) and produceAll() contract so the cache knows how to build entries. These methods are called by the cache itself — never directly — and are keyed by id for Persistable / Identified sub-types or by name for Named sub-types. See the Cached Type for the full contract.
Working with Cached Types
Retrieve cached instances
Read instances through the caching APIs rather than fetch, so reads are served from memory:
// By id (Persistable / Identified Types) — from the mixed-in Identified API
MyCachedData.forId("<id>")
// By name (Named Types) — from the mixed-in Named API
MyNamedCache.forName("<name>")
// Filtered set, evaluated against the cache
MyCachedData.find("data == 'ready'")
// Filtered by a single field/value
MyCachedData.findBy("data", "ready")
// Every cached instance of the Type
MyCachedData.allCached()find, findBy, and allCached accept an optional doNotProduceAll flag: pass true to return only what is already cached without triggering produceAll (relevant when the Type is annotated @cache(all=true)).
Retrieve by cache key
When you already hold the cache key, use the cache-key lookups:
// Produces and caches the entry if it is not present yet
MyCachedData.forCacheKey("<cacheKey>")
// Returns the entry only if it is already cached (never produces)
MyCachedData.findByCacheKey("<cacheKey>")Instance helpers
Given a cached instance, the following member functions are available:
| Method | Purpose |
|---|---|
cacheKey() | The cache key under which this instance is stored. |
isCached() | Whether this instance is currently in the cache. |
getCached() | The cached copy of this instance (produces it if absent). |
refreshCache() | Re-produces this instance and refreshes its cache entry. |
Shape the cache with @cache
Apply the Ann.Cache annotation to the Type to control caching behavior:
| Option | Effect |
|---|---|
maxSize | Maximum number of entries (MRU eviction). Mutually exclusive with all. |
all | Load and cache all instances on the first find operation; the cache has no size limit. Mutually exclusive with maxSize. |
ttl | Time-to-live; the entry is refreshed synchronously on access once it expires. |
ttr | Time-to-refresh; the entry is refreshed asynchronously after access (should be less than ttl). |
refresh | Prime the cache on node start and refresh it periodically. |
dependencies | Other Types this cache depends on; when any of them is invalidated, this cache is invalidated too (declarative cross-Type invalidation — see Dependency-based invalidation). |
include | Include spec for persistable cached Types — the fields loaded into the cached instance. |
doNotCacheNulls | Do not cache the fact that produce returned null for a key. |
softRef | (beta) Hold cached values via soft references so they can be garbage-collected under memory pressure; useful for large objects. A GC'd value is re-produced on next access, resetting its ttl/ttr. |
@cache(all=true, ttl='15m')
entity type MyCachedData mixes Cached {
data: any
}Dependency-based invalidation
A cached Type can declare other Types it derives from via @cache(dependencies=[...]). When any listed dependency Type is invalidated (an instance is created, updated, or removed), this Type's cache is invalidated automatically — so a cache built from another Type's data never serves values that outlived their source:
@cache(dependencies=['SourceType'])
entity type MyDerivedCache mixes Cached {
data: any
}Here, any create/update/remove on SourceType triggers invalidation of MyDerivedCache, broadcast to the other nodes per the active CacheBroadcastConfig.
How distributed invalidation works
When an entry is evicted on one node, that node builds a CacheMessage and hands it to the Cache.Invalidator, which broadcasts it to the other nodes so they evict the same entry locally. Each CacheMessage carries:
typ— the Type whose cache is affected (for example,App).key— the cache key to evict.cache— which cache on the Type (Cache.Invalidator.CacheKind:nativeCacheorrawJsonNativeCache).action— what receiving nodes should do (Cache.Invalidator.Action:evictLocalorclearLocal).level— the scope of the broadcast (Cache.Invalidator.Level:APP,ENV, orCLUSTER).sourceServerId/sourceAppId— where the message originated, so the source node does not act on its own message.
Broadcast levels
Invalidation is scoped by level so a message only reaches the nodes that share the affected cache:
| Level | Scope |
|---|---|
APP | All nodes running the current application. |
ENV | All nodes in the current environment. |
CLUSTER | All nodes in the cluster. |
Broadcast transports
The transport used to deliver a CacheMessage is selected by CacheBroadcastKind:
| Kind | Transport |
|---|---|
HTTP | Sends the message over direct HTTP connections to the other nodes (default). |
DB | Enqueues the message through the CacheQueue for the receiving nodes to pick up. |
Enabling and configuring invalidation
Remote cache invalidation is controlled by CacheBroadcastConfig, a Config that determines which broadcast levels are active and which transport is used. By default, APP and ENV level invalidation are enabled and the transport is HTTP.
CacheBroadcastConfig is an app-admin config. Setting it at ENV or CLUSTER overrides requires the corresponding Env Admin or Cluster Admin permissions.
Enable a broadcast level
Enable remote invalidation at a given level, applied at a given config override:
CacheBroadcastConfig.enable(Cache.Invalidator.Level.APP, ConfigOverride.APP)
CacheBroadcastConfig.enable(Cache.Invalidator.Level.ENV, ConfigOverride.ENV)enable sets the flag for that level and broadcasts to all nodes at that level so they pick up the new config.
Disable a broadcast level
CacheBroadcastConfig.disable(Cache.Invalidator.Level.APP, ConfigOverride.APP)Choose a transport
// HTTP (default)
CacheBroadcastConfig.setKind(CacheBroadcastKind.HTTP, ConfigOverride.APP)
// CacheQueue (DB) transport
CacheBroadcastConfig.setKind(CacheBroadcastKind.DB, ConfigOverride.APP)Reset to defaults
Clear the config at an override so broadcasting is determined by higher overrides again:
CacheBroadcastConfig.reset(ConfigOverride.APP)Monitoring caches
The Cache Type exposes monitor actions to inspect caches:
// List non-empty caches
Cache.list()
// Show memory usage across all caches
Cache.histo()histo accepts an optional Cache.Histo.Spec to group, filter, and sort the breakdown:
// Group by cache kind
Cache.histo(Cache.Histo.Spec.make({ group: 'kind' }))
// Filter to one Type's cache (filter is an Expr over the histogram entries)
Cache.histo(Cache.Histo.Spec.make({ filter: "contains(cache, 'MyType')" }))
// Print a formatted table
Cache.histo().print()Available group fields include cache, kind, entryClass, objectClass, path, pkg, and app; skipMemory skips the (relatively expensive) memory calculation for a faster listing.
Persistent disk cache — Cache.LocalDisk
For large objects that should survive JVM restarts, the platform provides Cache.LocalDisk: a persistent, LRU-evicted, disk-backed cache stored under /usr/local/share/c3/cache/{name}/. Unlike the in-memory Cached caches above, Cache.LocalDisk is app-unaware — if you need per-app/env isolation, partition it via the cache name or file paths yourself.
// Get (or create) a named disk cache — a matching Cache.LocalDisk.Config must exist
var cache = Cache.LocalDisk.forName('my-cache')
// Store / retrieve binary content by relative file path (used as-is, not encoded)
cache.put('c3/pkg/MyClass.class', binaryData)
var data = cache.get('c3/pkg/MyClass.class') // null if absent or expired
cache.contains('c3/pkg/MyClass.class') // existence check (no access-time update)
cache.remove('c3/pkg/MyClass.class') // remove one entry
cache.clear() // remove all entries
cache.stats() // Cache.LocalDisk.Stats (size, entry count, hit ratio, …)
cache.cleanup(true) // evict old/excess entries now (force = ignore interval)Eviction is governed by Cache.LocalDisk.Config (a Config whose name must match the cache name):
| Option | Default | Effect |
|---|---|---|
maxSizeMb | 500 | Max total size; on exceed, LRU-evicts oldest until under 80% of the limit. |
maxAgeDays | 30.0 | Entries older than this are always evicted (fractional days allowed). |
cleanupIntervalMins | 60 | Minimum interval between automatic cleanup runs. |
doNotTouchOnRead | false | If true, LRU tracks write time only (enable when the filesystem's mtime updates are unreliable). |
disabled | false | If true, put is a no-op and get returns null. |
Troubleshooting
Stale values on some nodes: Usually means invalidation is not enabled at the level shared by those nodes. Confirm CacheBroadcastConfig has the relevant level enabled at the appropriate override (see Enable a broadcast level). This is a common cause of a config appearing to have different values on different leader nodes — see the Config FAQ.