Level 2 Distributed Coherence Cache Framework
CoCache gives Spring Boot services local-memory read latency without serving stale data indefinitely. Each instance keeps an in-process copy (L2, Caffeine) in front of a shared copy (L1, Redis) in front of your data source. Redis serializes every write per key, so a slow load can never overwrite a newer invalidation, and every put or invalidate tells the other instances to drop their local copy in the same round trip (with the default atomic broadcast; sharded or non-atomic mode takes two round trips).
flowchart LR
App["Your code"] --> L2["L2: local Caffeine<br>(per instance)"]
L2 -- miss --> L1["L1: Redis<br>(shared, lease-fenced)"]
L1 -- miss --> Src["CacheLoader<br>(your database)"]
L1 -. "invalidation (Pub/Sub)" .-> Peers["Other instances' L2"]
Requirements: JDK 21+, Spring Boot 4, and Redis 7.0+ (or Valkey 7.2+): standalone, Sentinel or Cluster. Tests and local runs need no Redis (cocache.backend: memory). Plain Kotlin or Java works too, through CacheRuntime and CoCacheFactory.
Ask one question about each piece of data: if Redis loses this key, can this process rebuild it?
| Answer | Type | For |
|---|---|---|
| Yes | Cache<K, V> |
Rows a CacheLoader can fetch again; or, declared externallySourced() (@CoCache(source = EXTERNAL)), values that callers supply, such as Spring Cache methods |
| Yes | VersionedCache<K, V> |
A read model a versioned loader rebuilds, written by version (putIfNewer) so replays can't regress it |
| No | Store<K, V> |
Data whose only copy is Redis: sessions, revocation lists, one-time codes; compare-and-set with putIfMatch / deleteIfMatch |
| No | VersionedStore<K, V> |
Only-copy data whose writers must survive late or replayed writes |
| No | SetStore<K, E> |
A set whose only copy is Redis, with atomic add / remove |
Each type has only the operations that are safe for its role (a Store has no invalidate, a VersionedCache no put), and all of them read the same way, with async and suspending views. Choosing and Declaring Caches walks through the choice.
implementation("me.ahoo.cocache:cocache-spring-boot-starter:8.0.0")
implementation("org.springframework.boot:spring-boot-starter-data-redis")@CoCache(ttl = 120) // seconds; defaults: ttl 3600, ttlJitter 60, absentTtl 60, localMaxAge 300
interface UserCache : Cache<String, User> // CoCache generates the implementation
@EnableCoCache(caches = [UserCache::class])
@SpringBootApplication
class AppServer
@Configuration
class UserCacheConfiguration {
@Bean // matched to UserCache by its generic type; returning null caches "not found" for absentTtl
fun userLoader(repo: UserRepository) = CacheLoader<String, User> { id -> repo.findById(id) }
}
// usage
val user = userCache[id] // L2 → L1 → CacheLoader
userRepository.save(changed) // write: commit the source of truth first,
userCache.invalidate(changed.id) // then invalidate — every instance drops its copyPrefer no annotations? Declare a spec bean instead and inject Cache<String, User>:
@Bean
fun userSpec(mapper: ObjectMapper, repo: UserRepository): CacheSpec<String, User> = cacheSpec("user") {
codec = jacksonCodec<User>(mapper) // the application's Jackson 3 ObjectMapper; a spec always names its codec
loader = CacheLoader { id -> repo.findById(id) }
policy { ttl = Duration.ofMinutes(2) }
}Configuration comes in three layers, merged field by field. Every duration needs a unit:
cocache:
key-prefix: "${spring.application.name}:" # separates applications that share a Redis
defaults:
cache:
local-max-age: 2m # below the declarations: fills only fields they leave unset
overrides:
cache:
ttl: 30m # only tightens: no Cache keeps a value longer
caches:
"[UserCache]":
load-max-concurrent: 32 # one cache, set lastThe Quick Start walks through each step, and the Configuration Reference lists every key.
- Lease-fenced L1. A miss takes a lease in Redis, and only the lease holder may fill. Concurrent misses across the cluster load once when the loader finishes within
lease-wait. - Atomic invalidation. Delete and broadcast happen in one Lua script. Failed invalidations, and puts whose outcome is unknown, are retried in the background as invalidations.
- Negative caching. A loader that returns
nullstores "not found" forabsentTtl. - Refresh ahead. With
refreshWindow, one node per key reloads a hot value before it expires. - Batch reads.
getAllreads L1 in about one round trip and callsCacheLoader.loadAllonce. - Honest failures. A read without a source (
Store,VersionedStore,SetStore, an externally sourcedCache) that can't reach Redis returnsLookup.Unknowninstead of pretending the key is absent; writes to an only copy throw instead of retrying later. - Three-layer configuration.
cocache.defaultsbelow the declarations,cocache.overridesthat only tighten,cocache.caches.[name]for one cache. The actuator endpoint reports each field's effective value and where it came from. CoCacheFactoryfor libraries. A library creates its caches through theCoCacheFactorybean and locks the fields its correctness depends on, so application configuration can't weaken them.- Join views.
@JoinCacheablejoins two caches (for example an order and its user) with two batch reads: aJoinViewis read only, over any two types; aJoinCache, over twoCaches, can also invalidate. - Async API and coroutines.
cache.async()returnsCompletableFutures on the same cache without parking a thread, andcache.suspending()gives every type a suspending view, withSuspendingCacheLoaderfor loaders. A@CoCacheinterface can also extend a view, for exampleinterface UserCache : SuspendingCache<String, User>. - Spring Cache and transactions.
@Cacheable(sync = true)throughCoCacheSpringCacheManager;@CacheEvictandTransactionalInvalidatorinvalidate after the commit and after a rollback, and loads inside a rolled-back write transaction are invalidated. - Observability. Micrometer metrics, a
cocachehealth indicator, and thecocacheactuator endpoint, which can inspect, invalidate or broadcast a single key, and reports each cache's keyspace and effective policy. - Redis topologies. Standalone, Sentinel, and Cluster through Lettuce, with classic or sharded Pub/Sub (
cocache.redis.channel-mode).
- Bounded staleness. Races inside the protocol leave no stale value in Redis. Writes that bypass CoCache heal within
ttl. A lost invalidation message leaves a peer's local copy stale for at mostlocalMaxAge(5 minutes by default), and every channel reconnect clears L2. - No lost invalidations. A load that started before an invalidation can write neither Redis nor the local copy after it.
- Not linearizable. Peers see a change once the invalidation message arrives.
Consistency states the full model. The normative design and its invariants are in docs/architecture.md.
| You want to… | Read |
|---|---|
| Start | Guide: why CoCache, quick start, how it works |
| Choose and declare caches | Choosing and Declaring Caches, then one guide per type: Cache, VersionedCache, Store, VersionedStore, SetStore |
| Use CoCache | Keeping data fresh, JoinCache, Spring Cache, async, coroutines, testing, native images, configuration reference |
| Upgrade | Upgrading from 6.x and the changelog |
| Run it in production | Operations: deployment, observability, troubleshooting |
| Understand or extend it | Architecture: design, consistency, SPI and TCK |
| Change CoCache itself | CONTRIBUTING.md, then docs/architecture.md |
| Work on it as an AI agent | AGENTS.md to change CoCache itself; the agent skills (cocache-develop, cocache-extend) for applications that use it |
See CONTRIBUTING.md. Report security issues privately per SECURITY.md.