Keeping Data Fresh
A cache is only as fresh as the writes it hears about. This page covers how to tell CoCache about changes, and how to choose the lifetimes that bound everything it can't hear about.
The rule: write, then invalidate
Change the source of truth first. After the change is committed, invalidate the key.
repo.rename(id, name) // committed
userCache.invalidate(id) // every instance drops the keyInvalidating first is a bug, even though it looks safer. Between the invalidation and the commit, another request can miss, read the old row, and cache it. That stale value then lives until ttl. Invalidating after the commit closes that window: any load that started before the invalidation is fenced out, and any load that starts after it reads the new row.
For several keys, invalidateAll(keys) sends them in as few Redis scripts as possible.
invalidate or put?
invalidate(key) | put(key, value) | |
|---|---|---|
| Redis | Deletes the key | Overwrites the value |
| Other instances | Drop their L2 copy | Drop their L2 copy |
| This instance | Drops its L2 copy | Drops its L2 copy (doesn't fill it) |
| Next read | Loads from the source | Reads the new value from Redis |
| On Redis failure | Retried in the background (default) | Throws CacheWriteException |
Prefer invalidate. It can't cache a value that differs from the source, and the next read loads exactly what was committed. Use put only when the writer holds the final value and loading it again would be expensive. put(key, value, ttl) sets an explicit lifetime; plain put uses the cache's ttlOf or policy.
After either call returns, the next read on the same instance sees the change. Other instances see it once the invalidation message arrives, typically within milliseconds.
Batch writes
putAll writes several keys at once. Each key follows the same rules as put:
userCache.putAll(users.associateBy { it.id }) // ttlOf or policy, jittered per key
userCache.putAll(users.associateBy { it.id }, Duration.ofMinutes(5)) // one explicit lifetime- No stale write-back. The batch is one write: this instance drops its L2 copies of every key before and after Redis is written, and the overwrite destroys any load lease. A load that started before
putAllcan't cache its old value, neither in L2 nor in Redis. Likeput,putAlldoesn't fill this instance's L2. - One round trip, one message. Redis receives one script that writes every key and publishes one invalidation message for all of them. In Redis Cluster, keys are grouped by slot and split at
cocache.redis.invalidate-batch-size, and each group runs its own script concurrently. Each key is written atomically, but the batch as a whole is not. - Encoded-key collisions: if two keys encode to the same storage key, the later entry wins. An empty map does nothing.
- Failures: like
put,putAllthrowsCacheWriteExceptionif Redis fails, and some keys may already be written. Retrying is safe. SetCache.putAllreplaces each set, and an empty set clears its key, exactly likeput.
A custom Cache implementation that doesn't override putAll falls back to calling put once per entry.
Conditional writes
When Redis is the only copy and several writers update one value by reading it, changing it, and writing it back (for example a document that holds many records), a plain put loses updates: two writers read the same version and the second put overwrites the first. Since 6.7, putIfMatch writes only if Redis still holds the value the writer read, and getIfChanged reads that value from Redis together with its digest:
fun update(key: String, change: (Document?) -> Document) {
while (true) {
val current = cache.getIfChanged(key, null) // reads Redis, never L2
val (document, digest) = when (current) {
is ConditionalGet.Present -> current.value to current.digest
else -> null to null // Absent: no value yet
}
when (cache.putIfMatch(key, digest, change(document))) {
is ConditionalPut.Written -> return
is ConditionalPut.Conflict -> continue // someone else wrote first: read again
}
}
}- The digest is the SHA-1 of the value's bytes as the codec encoded them.
ConditionalGet.PresentandConditionalPut.Writtenboth return it. A codec that encodes equal values to different bytes (an unordered map, say) produces different digests. putIfMatch(key, expected, value)compares and writes in one Redis script. Withexpected = nullit writes only if Redis holds no value: the key is absent, a load lease is in place, or the key holds a negative entry. Otherwise it writes only if the value's digest equalsexpected. A conflict returnsConflict(current)(nullwhen Redis holds no value) and changes nothing: no write, no TTL change, no invalidation message. A successful write is aput: same TTL rules (ttlOfor policy, or thettlargument), same invalidation of every instance's L2, and this instance's next read sees it.getIfChanged(key, known)asks Redis whether the value still has digestknown. If it does, the answer isUnchangedand the value isn't transferred: Redis sends back two integers, so confirming a large value costs a round trip, not its size on the wire. It doesn't save Redis CPU: the script still reads the value and computes its SHA-1 every time, and Redis serves nothing else meanwhile (about 20 ms for an 8 MB value on Redis 7.4, of which about 13 ms is the read that a plain L1 read pays too). Size the revalidation interval with instances × values × size in mind. Otherwise it returnsPresent(value, digest)orAbsent. Use it to revalidate a value you hold in memory: keep the digest, and callgetIfChangedon a schedule or when an invalidation arrives.- Both bypass L2. They read and write Redis only, never load from the loader, and never fill L2. The cache's L2 may be older than what
getIfChangedreturns, withinlocalMaxAge. - Failures:
getIfChangedthrowsCacheReadExceptionwhen Redis fails, whateverread-failuresays, because there is nothing to degrade to.putIfMatchthrowsCacheWriteException. It isn't idempotent: after a timeout, callgetIfChangedfirst. If the current digest is the oneWrittenwould have returned, the write went through. - Without atomic broadcast (the
shardedchannel mode, or when the startup probe falls back), the invalidation is published after the script. If the write lands in Redis but that publish fails, the call throwsCacheWriteException. Replaying the same write then returnsConflict(current)whose digest is that of your own value, and CoCache publishes the invalidation again so other instances drop their stale copies. Other conflicts publish nothing. - Unreadable content is never treated as an empty value by one method and as a value by the other. A value written by a newer CoCache, or a set, makes both methods throw with a
ForeignFormatExceptioncause and leaves the key alone. A value the codec can't decode makesgetIfChangedthrow.putIfMatchcan't see the codec, so on such a value it only ever returnsConflictand never overwrites it. Content CoCache 6 didn't write (5.x data) followsforeign-format, as for a cache without a loader: withfail(the default) both methods throw; withreclaimit counts as no value, sogetIfChangedreturnsAbsentandputIfMatch(key, null, value)replaces it. - Custom backends must implement the conditional write atomically or not at all: the SPI's default
putIfMatchthrowsUnsupportedOperationException, which reaches the caller. - Every writer must use
putIfMatch. A plainputdoesn't check and still wins over a concurrent read-modify-write. SetCachedoesn't support them, since itsaddandremoveare already atomic. The async view and the suspending view have the same methods.
Versioned writes
Some writers know the version of every value they write: an event-sourced aggregate has one writer and strictly increasing versions, a CDC stream carries commit sequence numbers. The writer can push each new state into Redis right after the commit, but redelivery and replay send older versions again, and a plain put of an older version would replace the newer one. Since 6.8, a cache declared versioned stores the version next to each value and orders writes by it:
val views = runtime.create(cacheSpec<String, OrderView>("order-view") {
codec = jacksonCodec<OrderView>(mapper)
versionedLoader = CacheLoader { id ->
val state = repository.find(id) ?: return@CacheLoader null // never existed: plain negative entry
Versioned(if (state.deleted) null else state.view, state.version) // deleted at state.version: tombstone
}
})
// the writer, after each commit (also on replay and redelivery)
when (views.putIfNewer(id, view, version)) {
VersionedPut.Written -> Unit // newer: written and broadcast
is VersionedPut.Stale -> Unit // Redis already holds this version or a newer one
}
views.invalidateIfNewer(id, deletedAtVersion) // deletion: a tombstone that blocks older versions
views.getVersioned(id) // Versioned(view, version), or null- Declare it. Set
versioned = true, or aversionedLoader/versionedAsyncLoaderthat returnsVersioned(value, version)(the suspending builder hassuspendingVersionedLoader). A loader that returnsVersioned(null, n)reports a key deleted at versionn: the load writes the same tombstone asinvalidateIfNewer(key, n), so a replayed older state can't bring it back. Returningnullmeans the key never existed and caches a plain negative entry forabsent-ttl, which any versioned write replaces. The versioned methods throwUnsupportedOperationExceptionon any other cache, and creating a versioned cache fails at once on a backend that doesn't support versions. The Redis and in-memory backends do. putIfNewer(key, value, version[, ttl])compares and writes in one Redis script, so it's one round trip. It writes only whenversionis greater than the version in Redis, and returnsWritten. An equal version is a duplicate delivery and an older one is late; both returnStale(current)and change nothing: no write, no TTL change, no invalidation message. Versions compare as signed 64-bit numbers. A value without a version counts as older than any version: a plainput, a load from a loader that doesn't know versions, a negative entry, a load lease, or no key at all. A successful write is aput: it fences out every load in flight and evicts every instance's L2.- A load in flight never wins. If a reader holds the load lease while
putIfNewerlands, its write-back is refused, even when it read a higher version from the source. The writer's next version arrives throughputIfNeweranyway. A load that completes first stores the loader's version, and later writes compare against it. invalidateIfNewer(key, version[, ttl])writes a tombstone: readers see the key as absent (without calling the loader), andputIfNewerwith that version or an older one returnsStale. A newer version can bring the key back. The tombstone lives as long as a value would (ttlwith jitter, or thettlargument), notabsent-ttl. Once it expires, a late older version can be written again, so give tombstones a lifetime that covers your redelivery window.getVersioned(key)reads through likegetand returns the value with its version. It returnsnullfor a tombstone, the same as for an absent key; while the tombstone lives,putIfNewerwith its version or a lower one is still refused. A value without a version reportsVersioned.NO_VERSION.- Ordering holds while the entry lives. Once a value or tombstone expires, the key holds no version, and any version can be written again, including an older one. Size
ttl(and the tombstonettl) to your redelivery horizon. A writer that replays its stream to the head ends on the newest version anyway, so after an expiry the staleness is bounded by that replay. - Plain writes drop the version.
put,putAll,putIfMatchandinvalidatewrite content without a version, and so does expiry, after which any version can be written. Every writer that needs ordering must useputIfNewer/invalidateIfNewer. Keepinvalidatefor operator purges. - No refresh-ahead. A versioned cache rejects
refresh-window;cocache.defaults.refresh-windowskips versioned caches. Writers push new versions, so values don't need to be refreshed. - Failures:
putIfNewerthrowsCacheWriteExceptionwhen Redis fails. Retrying the same version is safe: if the first attempt went through, the retry returnsStale(version). Without atomic broadcast, aStaleresult whose version equals the one you passed also publishes the invalidation, in case the first attempt was written but its separate publish failed. An older version publishes nothing. Unreadable content is classified as for conditional writes. - Mixed versions: instances on 6.0 to 6.7 can share Redis with 6.8 and read versioned values, but their writes don't keep versions. Upgrade every writer of a versioned cache to 6.8.
- The async view and the suspending view have the same methods. A meter,
cocache.versioned.stale, counts refused versions.
Inside a transaction
Inside a Spring transaction, "after the commit" is not where your code runs. TransactionalInvalidator (registered by the starter when spring-tx is present) moves the invalidation there:
@Transactional
fun rename(id: String, name: String) {
repo.rename(id, name)
invalidator.invalidate(userCache, id) // runs in afterCommit
}@Transactional
public void rename(String id, String name) {
repo.rename(id, name);
invalidator.invalidate(userCache, id); // runs in afterCommit
}- Invalidations of one cache within one transaction are merged into a single
invalidateAll, deduplicated, in order. - On rollback, the registered keys are invalidated too. The loader runs on your thread and joins the transaction, so a read after your write may already have put the uncommitted row in Redis, where every instance would read it until
ttl. An extra invalidation is harmless. - A
REQUIRES_NEWinner transaction invalidates when it ends. - Late registrations are not lost. One made in another synchronization's
afterCommitruns right away, or inafterCompletionif it is the transaction's first. InafterCompletionitself, for example in an@TransactionalEventListener(AFTER_COMMIT), it invalidates immediately. - One failing cache doesn't stop the others: all are invalidated, then the first failure is thrown with the rest suppressed (only with
invalidate-failure: throw). After a rollback, Spring logs the failure instead of throwing it. - Without an active transaction, it invalidates immediately.
- Only thread-bound transactions (
PlatformTransactionManager, such as JDBC or JPA) are visible to it. In a reactive transaction (ReactiveTransactionManager, such as R2DBC, including@Transactionalsuspend functions), it invalidates immediately, which is before the commit. There, invalidate after the transaction ends, outside its boundary.
@CacheEvict and @CachePut on CoCache caches are deferred to after the commit as well, but skipped on rollback. See Spring Cache.
Writers outside your application
Batch jobs, CDC consumers, admin tools, and other services change the database without going through your caches. If they don't invalidate, caches stay stale until ttl.
On the JVM, such a writer uses ExternalInvalidator from cocache-core. It speaks the same protocol as the caches, with the same backend, and needs no cache declaration:
@Bean(destroyMethod = "close")
fun externalInvalidator(backend: CoherenceBackend) = ExternalInvalidator(backend)
// after committing the change:
externalInvalidator.invalidate("UserCache", "42") // namespace, encoded key
externalInvalidator.invalidate("UserCache", listOf("42", "43")) // several keys
// a cache with a non-default key prefix gets its own invalidator
val shopInvalidator = ExternalInvalidator(backend, "shop:")
shopInvalidator.invalidate("ProductCache", "7")ExternalInvalidator invalidator = new ExternalInvalidator(backend);
invalidator.invalidate("UserCache", "42");
invalidator.invalidate("UserCache", List.of("42", "43"));
new ExternalInvalidator(backend, "shop:").invalidate("ProductCache", "7");- Pass the cache's namespace (the cache name unless set) and the encoded key: the
toString()orkeyExpressionresult, as it appears in the storage key. - Pass
keyPrefixto the constructor when the cache doesn't usecocache:. - Calls are idempotent: if one throws (for example, the delete succeeded but the broadcast failed), retry it.
- It deletes the keys from Redis and broadcasts the invalidation (in one script with the default channel mode). Every instance, including caches in the same process, drops its L2 copy.
- It doesn't retry. If Redis fails, it throws; retry the call yourself.
- An empty encoded key throws
IllegalArgumentException; an empty collection does nothing. Afterclose(), every call throwsIllegalStateException.close()releases the invalidator's own store handles and doesn't close the backend.
Outside the JVM, any Redis client can do the same in two commands: DEL {keyPrefix}{namespace}:{encodedKey}, then PUBLISH cocache:inv:{namespace} with a small binary frame (SPUBLISH in sharded mode). Troubleshooting documents the frame format. Skipping the PUBLISH leaves other instances' L2 stale for up to localMaxAge.
Choosing lifetimes
Each lifetime bounds one kind of staleness. Pick each for the failure it covers:
| Setting | Default | Bounds | Shorter means | Guidance |
|---|---|---|---|---|
ttl | 1 h | Changes CoCache never hears about | More reloads | Long if every writer invalidates. ttl-forever is safe then. Shorter if some writers can't invalidate. |
ttl-jitter | 1 min | — | — | Spreads expiries so keys loaded together don't expire together. Effective spread is capped at ttl / 2. |
absent-ttl | 1 min | How long a newly created row stays invisible after a lookup cached its absence | More loads for missing keys | Keep it short, or invalidate the key when you create the row. |
local-max-age | 5 min | L2 staleness when an invalidation message is lost | One more Redis read per key per period | Costs nothing on the hit path. Lower it for data where minutes of drift matter. |
degraded-local-max-age | 10 s | Loads that couldn't be written to Redis | More loads while Redis is down | Rarely changed. |
lease-wait | 200 ms | — | — | Other instances wait this long for a load in progress. Raise it if your loader is slower, to keep one load per cluster. |
lease-ttl | 10 s | — | — | How long a crashed loader's lease blocks a key. Keep it above the loader's p99. |
cocache:
defaults:
local-max-age: 2m
caches:
"[UserCache]":
ttl-forever: true # every writer invalidates
"[PriceCache]":
ttl: 30s # fed by a system that can't invalidate
absent-ttl: 5sBare numbers are seconds. Write a unit for anything shorter: lease-wait: 500ms, not lease-wait: 0.5.
Refresh-ahead
When a hot key expires, the next reader pays the full load latency. Refresh-ahead hides that: a read that finds a value within refreshWindow of its Redis expiry returns it immediately and schedules one background reload.
cocache:
caches:
"[UserCache]":
ttl: 10m
refresh-window: 1m- The window must be less than
ttl − ttl-jitterand less thanlocal-max-age, or every fresh copy would start inside it. With the defaults (5 minlocal-max-age), the window must be under 5 minutes. - Only one instance per key wins the refresh token in Redis and calls the loader. The cluster reloads each key once.
- The other instances keep serving their copy and read Redis again after a back-off:
lease-wait(at least 100 ms), and never more than half the old value's remaining lifetime. So they pick up the reloaded value before the old one expires, and while the reload runs they read Redis a bounded number of times, however hot the key is. - A value that lives no longer than the window, from a short
ttlOforput(key, value, ttl), is not refreshed: it would start inside the window, and every hit would reload it. It expires and reloads on the next miss. Likewise, an instance that first reads a value inside its window doesn't start a refresh for that copy; copies on other instances still do. - Only caches with a loader refresh. Negative entries are not refreshed.
- A failed or skipped refresh only logs. The value expires normally, so staleness bounds never depend on refresh.
- Refreshes run on a bounded pool (at most 4 threads, queue of 1024). When it is full, refreshes are skipped and counted in
cocache.refresh.rejections.cocache.refresh-executornames anotherExecutorbean to use instead.
To turn off a window that a declaration enables, use any of: refresh-window: 0 in the properties, @CoCache(refreshWindow = 0), or refreshWindow = null in a customizer.
When Redis is down during a write
invalidate applies its local effects first (this instance's L2 is dropped). If the Redis delete fails:
- With a loader, the default (
invalidate-failure: retry) doesn't throw. Invalidations usually run after a commit, where an exception can't roll anything back. The delete goes to a bounded retry queue: up to 8 attempts, backing off from 100 ms to 10 s, at most 10 000 pending. - While the retry is pending, misses for that key on this instance skip Redis (which still holds the old value) and load from the source.
- If the retries are exhausted or the queue overflows, CoCache logs an error and counts
cocache.invalidations.dropped. Other instances can then reload the old value from Redis until it expires, so the bound is the value's remaining TTL. Withttl-foreverthere is no bound: invalidate again once Redis is back. - With
invalidate-failure: throw, the call throwsCacheWriteExceptioninstead, and you decide. - Without a loader,
throwis the default. Redis holds the only copy, so a delayed retry is unsafe: if another instance writes the key after the failure, the retry deletes that newer value. Deleting is only safe when a loader can reload the key. Settinginvalidate-failure: retryon such a cache accepts that risk. Meanwhile reads on this instance returnnullwithout caching it as a negative entry, soget(key, loader)still calls its loader.
put and putAll, and the SetCache writes add, remove, and clear, throw CacheWriteException. The outcome may be unknown after a timeout, but these operations are idempotent, so retrying is safe.
The retry queue's limits come from RetryPolicy on the CacheRuntime. To change them in Spring Boot, declare your own CacheRuntime bean (Configuration Reference). Watch the queue in the health details (pendingInvalidations) and in Observability.