Skip to content

Observability ​

CoCache reports through four channels, all provided by the starter when spring-boot-starter-actuator is on the classpath:

ChannelWhat for
Metrics (cocache.*)Trends and alerts
Tracing (cocache.load)Where a slow miss spent its time
Health indicator (cocache)Is this instance receiving invalidations?
Actuator endpoint (/actuator/cocache)Per-instance counters, inspecting one key, forcing an invalidation

Plus the logs. None of them touch the L2 hit path, except the L2 hit counter, which is off by default.

Metrics ​

The starter binds these meters to every MeterRegistry. Each carries a cache tag with the cache name. They read counters the engine keeps anyway, are cumulative since startup, and can't be turned off one by one. Caches created lazily are bound when their bean is initialized.

Prometheus names follow Micrometer's conventions: cocache.misses becomes cocache_misses_total, the timer cocache.loads becomes cocache_loads_seconds_count and cocache_loads_seconds_sum, and the gauge becomes cocache_local_size.

Reads ​

MeterTypeCounts
cocache.missescounterReads that missed L2, counted per call before concurrent misses for a key are coalesced. Reads answered by the KeyFilter are not included. For a SetCache: reads that found no set in L1.
cocache.hits{level=l2}counterReads answered by L2, including negative entries. Registered only with cocache.metrics.local-hits=true.
cocache.hits{level=l1}counterL2 misses answered by L1, including negative entries
cocache.filteredcounterReads the KeyFilter answered as "doesn't exist", without touching Redis
cocache.lease.waitscounterMisses that found another instance loading the key and waited for it
cocache.lease.wait.timeoutscounterMisses that stopped waiting (after lease-wait, or after the other lease's remaining time if that is shorter) and loaded themselves without writing L1

Every read (every key of a getAll) counts exactly once in hits{level=l2}, misses, or filtered. An L2 miss then counts in hits{level=l1} if Redis had the key, or in loads if the loader ran.

The L2 hit counter is off by default. Counting adds an atomic increment to every L2 hit, the hottest path, and costs measurable throughput under many threads. Turn it on with cocache.metrics.local-hits=true (without Spring: CacheRuntime.builder().recordLocalHits(true)). It applies to every cache of the runtime. With it off, L2 hits are your request rate minus cocache.misses and cocache.filtered.

Loads ​

MeterTypeCounts
cocache.loadsfunction timerLoader calls, with total time. A loadAll batch counts once. Includes calls that threw.
cocache.load.failurescounterLoader calls that threw. The exception reaches the caller and every coalesced waiter.
cocache.load.rejectionscounterLoads rejected by load-max-concurrent: no permit within load-acquire-timeout. The caller got SourceLoadRejectedException.
cocache.load.timeoutscounterAsyncCacheLoader loads that took longer than load-async-timeout. The caller got SourceLoadTimeoutException and the lease was released. See Async API.
cocache.refreshescounterRefresh-ahead tasks that ran
cocache.refresh.rejectionscounterRefreshes the refresh executor rejected. The value is served until it expires normally.

Fills and fences ​

MeterTypeCounts
cocache.fill.rejectionscounterLoads that held a lease, but Redis refused the fill: an invalidation destroyed the lease, the loader outlived lease-ttl, or the fill failed
cocache.degraded.fillscounterLoads written to L2 only, for degraded-local-max-age: Redis read failed, lease wait timed out, fill rejected, value in an unknown format, or an invalidation of the key still pending. A read without a loader isn't counted when Redis fails: it throws, or with an explicit read-failure: degrade returns null without caching anything (counted in cocache.remote.failures).
cocache.stale.discardscounterL2 fills dropped because an invalidation of the key arrived while the read was in flight. This is the L2 fence working.
cocache.reclaimscounterL1 values this instance couldn't decode (foreign content or codec failure). A separate reclaim script then converts the value into a lease, if it is still unchanged, and the instance reloads.
cocache.unknown.formatscounterL1 values written in a newer wire format than this instance knows (loaded from the source, nothing overwritten; without a loader the read throws under foreign-format: fail), unreadable content rejected by foreign-format: fail (the read throws CacheReadException; the default without a loader), and SetCache.add / remove and putIfMatch calls refused on unreadable content (they throw CacheWriteException and also count in cocache.write.failures), and getIfChanged calls that hit unreadable content.

Writes, invalidations, channel ​

MeterTypeCounts
cocache.remote.failurescounterRedis operations on the read side that failed: read, fill, release, reclaim, refresh
cocache.write.failurescounterput, putIfMatch, invalidate, invalidateAll, and SetCache writes that failed on Redis (first attempt). A putIfMatch conflict isn't a failure.
cocache.conditional.conflictscounterputIfMatch calls that didn't write because Redis held a different value (conditional writes). A steady rate means writers often race on the same key.
cocache.conditional.unchangedcountergetIfChanged calls that confirmed the value without transferring it
cocache.versioned.stalecounterputIfNewer / invalidateIfNewer calls that didn't write because Redis held that version or a newer one (versioned writes): duplicate deliveries and late, older versions
cocache.invalidation.retriescounterRetry attempts of failed invalidations
cocache.invalidations.droppedcounterInvalidations given up after all retries, on a full retry queue, or at shutdown. L1 still holds the old value.
cocache.invalidations.receivedcounterInvalidation messages from other instances or external writers (one per message, which may carry many keys)
cocache.resetscounterTimes this cache cleared its whole L2 because messages may have been lost: each confirmed (re)subscription, including the first one at startup, and each failed heartbeat
cocache.local.sizegaugeEntries in this instance's L2 (estimated)

Alerts ​

ConditionSeverityMeaningRunbook
increase(cocache_invalidations_dropped_total[5m]) > 0pageAn invalidation never reached Redis. Every instance may serve the old value until its ttl.Invalidations dropped
health cocache is DOWN for more than a minutepageThis instance can't receive invalidations, and its L2 is cleared at every heartbeatHit rate collapsed
rate(cocache_remote_failures_total[5m]) > 0 sustainedwarnRedis is failing. Reads go to the source.Redis down
rate(cocache_load_rejections_total[5m]) > 0warnCallers are getting SourceLoadRejectedExceptionLoad rejections
rate(cocache_resets_total[5m]) above zero outside deployswarnThe subscription connection is unstableHit rate collapsed
cocache_lease_wait_timeouts_total / cocache_lease_waits_total above 10%warnLoaders are slower than lease-wait, so concurrent misses across instances each loadLease wait timeouts
increase(cocache_unknown_formats_total[10m]) > 0 outside a deploywarnSome instance writes a newer formatUnknown format
rate(cocache_reclaims_total[5m]) > 0 sustainedwarnSomething writes values this instance can't decodeCorrupt payloads
rate(cocache_degraded_fills_total[5m]) risinginfoMore loads bypass L1, so the source carries more loadRedis down

Tracing ​

When micrometer-observation is on the classpath and the context has an ObservationRegistry bean (Spring Boot's ObservationAutoConfiguration provides one with the actuator), the starter registers the bean cocacheLoadObserver, a MicrometerLoadObserver. It wraps every source load in a Micrometer Observation. Your tracing bridge (for example micrometer-tracing-bridge-otel) turns it into a span, and Spring Boot's metrics handler into a timer.

FieldValue
Namecocache.load
Contextual (span) namecocache {operation} {cache}, for example cocache load UserCache
cache (low cardinality)The cache name
operation (low cardinality)load (a miss, get(key, loader), or a batch falling back to one key), loadAll (one batch load), refresh (a refresh-ahead load), lease.wait (waiting for another instance's lease)
outcome (low cardinality)success or error, plus timeout when an async load exceeds load-async-timeout. On error the exception is recorded on the observation and rethrown unchanged.
key.count (high cardinality, spans only)Keys in the call: 1, or the batch size for loadAll

What is observed, and what isn't:

  • Only the loader call itself, and the wait for another instance's lease when there is one. L2 hits, L1 reads, and fills create no observation, and with no observer configured the engine doesn't even allocate. A load rejected by load-max-concurrent is not observed.
  • The key is never recorded: it is high cardinality and may be personal data.
  • The loader runs on the caller's thread inside the observation's scope, so spans your loader creates (JDBC, HTTP) become children of cocache.load, which is itself a child of the request span. Refresh-ahead runs on the refresh executor, so its spans have a parent only if that executor propagates context.
  • An async load is observed from the call to loadAsync until its future completes. The synchronous part of loadAsync runs inside the observation's scope, so a reactive client that captures the context there (through Micrometer context propagation) creates child spans.
  • With Spring Boot's metrics, each observation also feeds a cocache.load timer and a cocache.load.active long task timer, tagged cache, operation, outcome (plus Micrometer's own error tag on the timer). They overlap with the cocache.loads function timer, which stays the cheap always-on one.

Turn it off with cocache.observation.enabled: false, or with Spring Boot's management.observations.enable.cocache: false. A LoadObserver bean of your own replaces the Micrometer one. Without Spring, pass it to CacheRuntime.builder().loadObserver(…). Implementations must run the block once on the calling thread and rethrow its exceptions unchanged. To see async loads, also override observeAsync: the default runs the block without observing it.

Health indicator ​

The starter registers the health contributor cocache (bean cocacheHealthIndicator). It reads in-memory state only and never calls Redis, so it is cheap to poll. Turn it off with management.health.cocache.enabled: false.

StatusWhen
DOWNThe last heartbeat of the Redis subscription connection failed. Invalidations from other instances may be missed, and this instance's L2 has been cleared. L1 may still work.
UPOtherwise, including while invalidations are being retried

GET /actuator/health/cocache (with management.endpoint.health.show-details allowing details):

json
{
  "status": "UP",
  "details": {
    "backend": "RedisBackend",
    "format": 1,
    "atomicBroadcast": true,
    "channelMode": "pubsub",
    "heartbeat": { "healthy": true },
    "pendingInvalidations": 0,
    "caches": {
      "UserCache": { "pendingInvalidations": 0, "localSize": 8123 }
    }
  }
}
DetailMeaning
backendRedisBackend, InMemoryBackend, or your own class
formatWire format version this instance writes
atomicBroadcastfalse means writes publish after the script, see channel mode
channelModepubsub or sharded (Redis only)
heartbeathealthy, and error with the last failure (Redis only). Recovers on the next successful ping.
pendingInvalidationsKeys whose invalidation failed on Redis and is being retried, in total and per cache. Until a retry succeeds, misses for these keys skip L1.
caches.*.localSizeL2 entries (estimated)

Pending invalidations don't change the status: the retry queue converges on its own, and a final failure shows in cocache.invalidations.dropped.

Readiness. Leave cocache out of the liveness group: a restart doesn't fix Redis. Whether to add it to readiness (management.endpoint.health.group.readiness.include: readinessState,cocache) is a trade-off. While DOWN, the instance serves from L1 and the source, with staleness bounded by local-max-age, so it usually is still fit to serve. Add it only if missing invalidations is worse than losing capacity. Be aware that a Redis outage then takes every instance out of rotation.

Actuator endpoint ​

The endpoint cocache exists only when it is available, so expose it:

yaml
management:
  endpoints:
    web:
      exposure:
        include: health, cocache
RequestEffectScope
GET /actuator/cocacheReport for every cachethis instance
GET /actuator/cocache/{name}Report for one cachethis instance
DELETE /actuator/cocache/{name}Clears L2 for this cache. L1 is untouched. Loads in flight don't write their result into L2.this instance
GET /actuator/cocache/{name}/{key}Inspects one key in L2 and L1this instance, plus one Redis read
DELETE /actuator/cocache/{name}/{key}Invalidates one keyevery instance

{name} is the cache name. For an unknown name, GET and DELETE return 404 and change nothing. Up to 6.9.1, DELETE returned 204, so a misspelled name looked like success. A successful DELETE /actuator/cocache/{name} returns 204, and a successful per-key DELETE returns 200 with a report. An empty {key} is rejected with 400. Spring drops empty path segments and surrounding whitespace, so a key containing //, a leading or trailing /, or surrounding spaces can't be addressed exactly through the endpoint.

A cache report:

json
{
  "name": "UserCache",
  "localSize": 8123,
  "loads": 5120,
  "loadNanos": 912000000,
  "metrics": { "remoteHits": 40211, "misses": 45342, "filtered": 0, "loadFailures": 2, "loadRejected": 0, "leaseWaits": 31, "leaseWaitTimeouts": 0, "fillRejected": 1, "degradedFills": 1, "staleFillsDiscarded": 3, "reclaims": 0, "unknownFormats": 0, "refreshes": 0, "refreshRejected": 0, "remoteFailures": 0, "invalidationsReceived": 977, "writeFailures": 0, "invalidationRetries": 0, "invalidationsDropped": 0, "resets": 1, "conditionalUnchanged": 0, "conditionalConflicts": 0, "staleVersions": 0 }
}

The metrics keys map to the meters in order: localHits is cocache.hits{level=l2} (present only when the L2 hit counter is on), remoteHits is cocache.hits{level=l1}, loadRejected is cocache.load.rejections, fillRejected is cocache.fill.rejections, staleFillsDiscarded is cocache.stale.discards, refreshRejected is cocache.refresh.rejections, loadTimeouts is cocache.load.timeouts, and the rest match their meter names. loads and loadNanos are the cocache.loads timer.

Encoded keys ​

{key} is the encoded key: the part of the Redis key after {keyPrefix}{namespace}:. That is the key's toString(), or the result of the cache's keyExpression, so the endpoint works for any key type.

Cache keyEncoded keyRequest
"42" in UserCache42/actuator/cocache/UserCache/42
OrderId(tenant=7, id=9) as a data classOrderId(tenant=7, id=9)/actuator/cocache/OrderCache/OrderId(tenant=7,%20id=9)
keyExpression = "#{tenant}/#{id}"7/9/actuator/cocache/OrderCache/7/9
  • A / in the key is written as is: every path segment after the cache name is joined with /.
  • Percent-encode spaces, %, ?, and #.
  • Keys with empty segments (a//b, a leading or trailing /), . or .. segments, ;, or an encoded %2F may be changed or rejected by the web server's path handling. For those, call CacheAdmin from code: runtime.admin("OrderCache")?.inspect(encodedKey).

Inspecting a key ​

bash
curl -s localhost:8080/actuator/cocache/UserCache/42
json
{
  "cache": "UserCache",
  "storageKey": "cocache:UserCache:42",
  "local": { "state": "VALUE", "value": "User(id=42, name=Ada)", "expiresAt": "2026-10-19T08:05:00Z", "refreshAt": null, "expired": false },
  "remote": { "state": "VALUE", "value": "User(id=42, name=Ada)", "ttl": "PT58M", "format": 1, "error": null },
  "invalidationPending": false
}
FieldValues
storageKeyThe full Redis key, for redis-cli
localThis instance's L2 copy, or null. state: VALUE, ABSENT (negative entry), or UNRESOLVED (a miss on a cache without a loader). expired: true is a copy past its expiresAt that Caffeine hasn't removed yet. It is never served.
remote.stateVALUE, ABSENT (negative entry), SET (SetCache), LEASED (an instance is loading it, ttl is the lease's remaining time), MISS, UNREADABLE (foreign content, a newer format, wrong shape, or a codec failure, with the reason in error), or ERROR (the Redis read failed)
remote.ttlRemaining lifetime in L1, null if it never expires
valueThe value decoded with the cache's codec, as toString(), cut at 256 characters
invalidationPendingAn invalidation of this key failed on Redis and is still being retried. Until it succeeds, misses skip L1.

Inspecting is read-only. It doesn't call the loader, take a lease or refresh token, fill either level, coalesce with loads, count in metrics, reclaim corrupt values, or disturb Caffeine's eviction statistics. It costs one Redis read.

How to read the result:

local vs. remoteMeaning
Same valueConsistent
local older than remoteThis instance missed an invalidation. It heals by local.expiresAt at the latest. See stale on one node.
local present, remote MISSThe key was invalidated or expired in L1. If local is older than the invalidation, this instance missed the message.
Both oldNothing invalidated the key after the source changed. See stale everywhere.
remote UNREADABLESee corrupt payloads or unknown format

Invalidating a key ​

bash
curl -s -X DELETE localhost:8080/actuator/cocache/UserCache/42
# {"cache":"UserCache","key":"42"}

This runs the normal invalidation path, as cache.invalidate(key) does: L1 is deleted, every instance drops its L2 copy, and the next read loads from the source. It is idempotent. If Redis fails, it follows the cache's invalidate-failure setting (by default, with a loader, queued for retry and 200 returned; with throw, the default without a loader, an error). On a SetCache it is clear(key), which deletes the set's only copy.

Logs ​

Loggers live under me.ahoo.cocache. Lines worth an alert or a dashboard:

LevelMessage (prefix)Meaning
ERRORCache[…] - invalidate […] dropped: L1 keeps the stale value until its remote TTL[…] expires.An invalidation was given up. Same as cocache.invalidations.dropped.
WARNCache[…] - invalidate […] failed, retrying.An invalidation failed on Redis and was queued
WARNInvalidation channel heartbeat failed - reset all subscribers and force reconnect.The subscription connection failed its ping. Every L2 is cleared.
WARNInvalidation channel - cannot force reconnect of the subscription connection, rely on TCP keepalive.Redis Cluster in pubsub mode: CoCache can't drop the connection itself, so it relies on TCP keepalive
WARNInvalidation channel[…] unsubscribed by server (slot migration) - reset subscribers and resubscribe.sharded mode, slot moved. Normal during resharding.
WARNInvalidation channel[…] - subscribed on […] but slot is owned by […] - reset subscribers and resubscribe.sharded mode, failover detected by the heartbeat
WARNInvalidation channel[…] - resubscribe failed, retry on next heartbeat.Resubscription will be retried
WARNInvalidation channel[…] - subscribe acknowledgement not received in …Redis didn't confirm a subscription within command-timeout
WARNIgnore malformed invalidation frame on channel[…]Someone publishes non-CoCache messages on a CoCache channel. See external invalidation.
WARNCache[…] - key[…] - remote … failed, degrading.A Redis read, fill, release, or reclaim failed. The read was served from the source.
WARNCache[…] - key[…] - corrupted payload, reclaim and reload.An L1 value couldn't be decoded
WARNCache[…] - key[…] - refresh failed. / refresh rejected.Refresh-ahead didn't run or failed. The value expires normally.
WARNAtomic broadcast is not supported by this Redis deployment - publish after writes instead.Startup probe fell back, see channel mode
WARN@Cacheable on … uses CoCache cache … without sync = true …A Spring Cache method isn't lease-protected, see Spring Cache
WARNCoCache uses the in-memory backend …cocache.backend=memory in a deployed environment is a misconfiguration
INFOInvalidation channel[…] subscribed - reset subscribers. and Cache[…] - reset: clear local copies.A (re)subscription cleared L2. Expected at startup and after reconnects.

Released under the Apache License 2.0.