Skip to content

Cache Coherence and Event Bus

Cache coherence is the defining feature of CoCache. When one application instance modifies or evicts a cache entry, all other instances must invalidate their local L2 caches to prevent stale reads. This is achieved through a publish-subscribe event bus pattern built around CacheEvictedEvent.

Core Interfaces

The coherence system is defined by three interfaces in the cocache-api module:

mermaid
classDiagram
    class CacheEvictedEventBus {
        <<interface>>
        +publish(event: CacheEvictedEvent)
        +register(subscriber: CacheEvictedSubscriber)
        +unregister(subscriber: CacheEvictedSubscriber)
    }

    class CacheEvictedSubscriber {
        <<interface>>
        +onEvicted(cacheEvictedEvent: CacheEvictedEvent)
    }

    class CacheEvictedEvent {
        +cacheName: String
        +key: String
        +publisherId: String
    }

    class NamedCache {
        <<interface>>
        +cacheName: String
    }

    CacheEvictedEventBus --> CacheEvictedEvent : publishes
    CacheEvictedEventBus --> CacheEvictedSubscriber : manages
    CacheEvictedSubscriber ..|> NamedCache
    CacheEvictedEvent ..|> NamedCache

    style CacheEvictedEventBus fill:#2d333b,stroke:#6d5dfc,color:#e6edf3
    style CacheEvictedSubscriber fill:#2d333b,stroke:#6d5dfc,color:#e6edf3
    style CacheEvictedEvent fill:#2d333b,stroke:#6d5dfc,color:#e6edf3
    style NamedCache fill:#2d333b,stroke:#6d5dfc,color:#e6edf3
InterfaceSourceRole
CacheEvictedEventBusCacheEvictedEventBus.ktPublish/subscribe registry for eviction events
CacheEvictedSubscriberCacheEvictedSubscriber.ktReceives eviction notifications
CacheEvictedEventCacheEvictedEvent.ktCarries cacheName, key, and publisherId

The CacheEvictedEvent data class carries three fields:

  • cacheName -- identifies which cache was affected (enables subscribers to filter by cache name)
  • key -- the specific cache key that was modified or evicted
  • publisherId -- the clientId of the instance that published the event (used for self-eviction filtering)

Implementations

CoCache provides three CacheEvictedEventBus implementations, each suited to a different deployment scenario:

mermaid
graph TD
    subgraph sg_8 ["CacheEvictedEventBus Implementations"]

        Interface["CacheEvictedEventBus<br>interface"]

        Guava["GuavaCacheEvictedEventBus<br>(in-process, single JVM)"]
        Redis["RedisCacheEvictedEventBus<br>(distributed, Redis Pub/Sub)"]
        NoOp["NoOpCacheEvictedEventBus<br>(disabled, no-op)"]
    end

    Interface --> Guava
    Interface --> Redis
    Interface --> NoOp

    style Interface fill:#2d333b,stroke:#6d5dfc,color:#e6edf3
    style Guava fill:#2d333b,stroke:#6d5dfc,color:#e6edf3
    style Redis fill:#2d333b,stroke:#6d5dfc,color:#e6edf3
    style NoOp fill:#2d333b,stroke:#6d5dfc,color:#e6edf3

GuavaCacheEvictedEventBus (In-Process)

GuavaCacheEvictedEventBus wraps a Guava EventBus for in-process pub/sub. It is the default implementation used when no distributed event bus is configured. All DefaultCoherentCache instances within the same JVM share one GuavaCacheEvictedEventBus, so events propagate across caches in a single application.

kotlin
class GuavaCacheEvictedEventBus(
    private val eventBus: EventBus = EventBus()
) : CacheEvictedEventBus {
    private val subscribers = ConcurrentHashMap<CacheEvictedSubscriber, CacheEvictedSubscriberAdapter>()

    override fun publish(event: CacheEvictedEvent) {
        eventBus.post(event)
    }

    override fun register(subscriber: CacheEvictedSubscriber) {
        subscribers.computeIfAbsent(subscriber) {
            CacheEvictedSubscriberAdapter(it).also { adapter ->
                eventBus.register(adapter)
            }
        }
    }
}

The adapter class CacheEvictedSubscriberAdapter bridges between Guava's @Subscribe annotation and the CacheEvictedSubscriber.onEvicted() method. The subscriber map (ConcurrentHashMap) prevents duplicate registrations.

RedisCacheEvictedEventBus (Distributed)

RedisCacheEvictedEventBus uses Redis Pub/Sub for cross-instance event propagation. When publish() is called, it sends the eviction message to a Redis channel named after the cacheName. All instances subscribed to that channel receive the notification.

kotlin
class RedisCacheEvictedEventBus(
    private val redisTemplate: StringRedisTemplate,
    private val listenerContainer: RedisMessageListenerContainer
) : CacheEvictedEventBus {

    override fun publish(event: CacheEvictedEvent) {
        redisTemplate.convertAndSend(event.cacheName, EvictedEvents.asMessage(event.key, event.publisherId))
    }

    override fun register(subscriber: CacheEvictedSubscriber) {
        subscribers.computeIfAbsent(subscriber) {
            MessageListenerAdapter(it).also { listener ->
                listenerContainer.addMessageListener(listener, ChannelTopic(it.cacheName))
            }
        }
    }
}

NoOpCacheEvictedEventBus (Disabled)

NoOpCacheEvictedEventBus is a singleton that does nothing. It is useful for single-instance deployments or testing scenarios where coherence is not needed.

EvictedEvents Codec

The EvictedEvents object handles encoding and decoding of Redis Pub/Sub messages. It uses @@ as the delimiter to pack key and clientId into a single message body:

kotlin
object EvictedEvents {
    private const val DELIMITER = "@@"

    fun fromMessage(message: Message): CacheEvictedEvent {
        val cacheName = message.channel.decodeToString()
        val msgBody = message.body.decodeToString()
        val clientIdWithKey = msgBody.split(DELIMITER.toRegex())
        require(2 == clientIdWithKey.size)
        return CacheEvictedEvent(cacheName, clientIdWithKey[0], clientIdWithKey[1])
    }

    fun asMessage(key: String, clientId: String): String {
        return key + DELIMITER + clientId
    }
}

The cacheName is encoded as the Redis channel name, while key and clientId are packed into the message body.

Cross-Instance Invalidation Flow

The following diagram shows how a cache modification on Instance A propagates to Instance B:

mermaid
sequenceDiagram
autonumber
    participant App as Instance A<br>(Publisher)
    participant CC_A as DefaultCoherentCache<br>(Instance A)
    participant EB as CacheEvictedEventBus<br>(Redis Pub/Sub)
    participant CC_B as DefaultCoherentCache<br>(Instance B)
    participant L2_B as ClientSideCache<br>(Instance B)

    App->>CC_A: setCache(key, value)
    CC_A->>CC_A: Write to L2 + L1
    CC_A->>EB: publish(CacheEvictedEvent<br>cacheName, key, clientId_A)

    EB->>CC_B: onEvicted(event)
    CC_B->>CC_B: Check: cacheName matches?
    Note over CC_B: Yes -- same cache name
    CC_B->>CC_B: Check: publisherId == clientId_B?
    Note over CC_B: No -- different instance
    CC_B->>L2_B: evict(key)
    L2_B-->>CC_B: L2 entry removed

    Note over CC_B: Next read for this key<br>will fetch fresh value from L1 or L0

Self-Eviction Filtering

The onEvicted() handler in DefaultCoherentCache performs two critical checks before evicting the local L2 cache:

kotlin
@Subscribe
override fun onEvicted(cacheEvictedEvent: CacheEvictedEvent) {
    // Filter 1: ignore events for different caches
    if (cacheEvictedEvent.cacheName != cacheName) {
        return
    }
    // Filter 2: ignore self-published events
    if (cacheEvictedEvent.publisherId == clientId) {
        return
    }
    // Only evict L2 for events from other instances
    clientSideCache.evict(cacheEvictedEvent.key)
}

Why filter self-published events? When Instance A calls setCache() or evict(), it already modifies its own L2 cache directly. Publishing the event and then receiving it back would cause a redundant L2 eviction (or worse, evict a value that was just written). The publisherId == clientId check at line 169 prevents this.

Why filter by cacheName? A single application may have multiple DefaultCoherentCache instances (one per cache interface). All of them subscribe to the same event bus, so the cacheName check at line 160 ensures each instance only reacts to events relevant to its own cache.

Registration Lifecycle

When a DefaultCoherentCache is constructed, it registers itself as a subscriber with the event bus. The @Subscribe annotation on onEvicted() is recognized by Guava EventBus (for in-process mode) and the MessageListenerAdapter handles Redis Pub/Sub messages (for distributed mode).

mermaid
flowchart LR
    subgraph sg_9 ["Registration Flow"]

        Create["CoherentCacheFactory<br>creates DefaultCoherentCache"]
        Register["cacheEvictedEventBus<br>.register(this)"]
        Listen["Listening for events"]
        Unregister["cacheEvictedEventBus<br>.unregister(this)"]
    end

    Create --> Register --> Listen --> Unregister

    style Create fill:#2d333b,stroke:#6d5dfc,color:#e6edf3
    style Register fill:#2d333b,stroke:#6d5dfc,color:#e6edf3
    style Listen fill:#2d333b,stroke:#6d5dfc,color:#e6edf3
    style Unregister fill:#2d333b,stroke:#6d5dfc,color:#e6edf3

Comparison of EventBus Implementations

FeatureGuavaCacheEvictedEventBusRedisCacheEvictedEventBusNoOpCacheEvictedEventBus
ScopeSingle JVM (in-process)Cross-instance (distributed)None
TransportGuava EventBusRedis Pub/SubN/A
ChannelN/A (direct method call)cacheName as Redis channelN/A
SerializationNone (object reference)EvictedEvents codec (key@@clientId)N/A
Dependenciescocache-core onlycocache-spring-rediscocache-core only
SourceGuavaCacheEvictedEventBus.kt:25RedisCacheEvictedEventBus.kt:32NoOpCacheEvictedEventBus.kt:20

Source References

FileLine(s)Description
CacheEvictedEventBus.kt20-24Core event bus interface
CacheEvictedEvent.kt21-39Event data class with cacheName, key, publisherId
CacheEvictedSubscriber.kt22-24Subscriber interface with onEvicted()
GuavaCacheEvictedEventBus.kt25-66In-process Guava EventBus implementation
RedisCacheEvictedEventBus.kt32-71Distributed Redis Pub/Sub implementation
EvictedEvents.kt19-33Message codec for Redis Pub/Sub
DefaultCoherentCache.kt158-181onEvicted handler with self-eviction filtering
NoOpCacheEvictedEventBus.kt20-24No-op implementation

Released under the Apache License 2.0.