C3 AI Documentation Home

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:

  • Singleton and DefaultInstance (accessed via inst())
  • Config (accessed via forConfigKey(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):

Text
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:

JavaScript
// 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:

JavaScript
// 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:

MethodPurpose
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:

OptionEffect
maxSizeMaximum number of entries (MRU eviction). Mutually exclusive with all.
allLoad and cache all instances on the first find operation; the cache has no size limit. Mutually exclusive with maxSize.
ttlTime-to-live; the entry is refreshed synchronously on access once it expires.
ttrTime-to-refresh; the entry is refreshed asynchronously after access (should be less than ttl).
refreshPrime the cache on node start and refresh it periodically.
dependenciesOther Types this cache depends on; when any of them is invalidated, this cache is invalidated too (declarative cross-Type invalidation — see Dependency-based invalidation).
includeInclude spec for persistable cached Types — the fields loaded into the cached instance.
doNotCacheNullsDo 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.
Text
@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:

Text
@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: nativeCache or rawJsonNativeCache).
  • action — what receiving nodes should do (Cache.Invalidator.Action: evictLocal or clearLocal).
  • level — the scope of the broadcast (Cache.Invalidator.Level: APP, ENV, or CLUSTER).
  • 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:

LevelScope
APPAll nodes running the current application.
ENVAll nodes in the current environment.
CLUSTERAll nodes in the cluster.

See Cache.Invalidator.Level.

Broadcast transports

The transport used to deliver a CacheMessage is selected by CacheBroadcastKind:

KindTransport
HTTPSends the message over direct HTTP connections to the other nodes (default).
DBEnqueues 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.

Enable a broadcast level

Enable remote invalidation at a given level, applied at a given config override:

JavaScript
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

JavaScript
CacheBroadcastConfig.disable(Cache.Invalidator.Level.APP, ConfigOverride.APP)

Choose a transport

JavaScript
// 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:

JavaScript
CacheBroadcastConfig.reset(ConfigOverride.APP)

Monitoring caches

The Cache Type exposes monitor actions to inspect caches:

JavaScript
// 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:

JavaScript
// 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.

JavaScript
// 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):

OptionDefaultEffect
maxSizeMb500Max total size; on exceed, LRU-evicts oldest until under 80% of the limit.
maxAgeDays30.0Entries older than this are always evicted (fractional days allowed).
cleanupIntervalMins60Minimum interval between automatic cleanup runs.
doNotTouchOnReadfalseIf true, LRU tracks write time only (enable when the filesystem's mtime updates are unreliable).
disabledfalseIf 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.

See also

Was this page helpful?