Skip to content

cocache-core Module

The cocache-core module is the engine of CoCache. It contains the default implementations of every interface defined in cocache-api, plus the proxy-based caching mechanism, TTL computation with jitter, key filtering, the JoinCache system, and the in-memory event bus.

Module Dependencies

mermaid
graph LR
    subgraph sg_37 ["cocache-core Dependencies"]

        api["cocache-api"]
        style api fill:#2d333b,stroke:#6d5dfc,color:#e6edf3

        core["cocache-core"]
        style core fill:#2d333b,stroke:#6d5dfc,color:#e6edf3

        cosid["cosid-core<br>(CoSid ID generation)"]
        style cosid fill:#2d333b,stroke:#6d5dfc,color:#e6edf3

        kotlin["kotlin-reflect"]
        style kotlin fill:#2d333b,stroke:#6d5dfc,color:#e6edf3

        spel["spring-expression<br>(SpEL parsing)"]
        style spel fill:#2d333b,stroke:#6d5dfc,color:#e6edf3

        guava["guava<br>(compileOnly)"]
        style guava fill:#2d333b,stroke:#6d5dfc,color:#e6edf3

        caffeine["caffeine<br>(compileOnly)"]
        style caffeine fill:#2d333b,stroke:#6d5dfc,color:#e6edf3

        api --> core
        cosid --> core
        kotlin --> core
        spel --> core
        guava -.-> core
        caffeine -.-> core
    end

DefaultCoherentCache -- The Heart of CoCache

DefaultCoherentCache orchestrates the two-level caching strategy. It holds references to the client-side cache (L2), distributed cache (L1), cache source (L0), key filter, key converter, and the event bus.

Read Path (getCache)

mermaid
flowchart TB
    subgraph sg_38 ["DefaultCoherentCache.getCache(key)"]

        start["getCache(key)"]
        style start fill:#2d333b,stroke:#6d5dfc,color:#e6edf3

        convert["keyConverter.toStringKey(key)"]
        style convert fill:#2d333b,stroke:#6d5dfc,color:#e6edf3

        l2["Check L2 (clientSideCache)"]
        style l2 fill:#2d333b,stroke:#6d5dfc,color:#e6edf3

        l2_hit{"L2 hit<br>& not expired?"}
        style l2_hit fill:#2d333b,stroke:#6d5dfc,color:#e6edf3

        l2_evict["Evict expired L2 entry"]
        style l2_evict fill:#2d333b,stroke:#6d5dfc,color:#e6edf3

        filter{"keyFilter.notExist(key)?"}
        style filter fill:#2d333b,stroke:#6d5dfc,color:#e6edf3

        missing["Return missingGuard"]
        style missing fill:#2d333b,stroke:#6d5dfc,color:#e6edf3

        l1["Check L1 (distributedCache)"]
        style l1 fill:#2d333b,stroke:#6d5dfc,color:#e6edf3

        l1_hit{"L1 hit<br>& not expired?"}
        style l1_hit fill:#2d333b,stroke:#6d5dfc,color:#e6edf3

        l1_populate["Populate L2 from L1"]
        style l1_populate fill:#2d333b,stroke:#6d5dfc,color:#e6edf3

        lock["Acquire fine-grained<br>lock (cacheKey)"]
        style lock fill:#2d333b,stroke:#6d5dfc,color:#e6edf3

        recheck["Re-check L2 + L1<br>(double-check)"]
        style recheck fill:#2d333b,stroke:#6d5dfc,color:#e6edf3

        source["cacheSource.loadCacheValue(key)"]
        style source fill:#2d333b,stroke:#6d5dfc,color:#e6edf3

        source_hit{"Source found?"}
        style source_hit fill:#2d333b,stroke:#6d5dfc,color:#e6edf3

        set_both["Set L2 + L1<br>Publish CacheEvictedEvent"]
        style set_both fill:#2d333b,stroke:#6d5dfc,color:#e6edf3

        set_missing["Set missing guard<br>in L2 + L1"]
        style set_missing fill:#2d333b,stroke:#6d5dfc,color:#e6edf3

        release["Release lock"]
        style release fill:#2d333b,stroke:#6d5dfc,color:#e6edf3

        start --> convert --> l2 --> l2_hit
        l2_hit -->|yes| start
        l2_hit -->|no| l2_evict --> filter
        filter -->|yes| missing
        filter -->|no| l1 --> l1_hit
        l1_hit -->|yes| l1_populate --> start
        l1_hit -->|no| lock --> recheck --> source --> source_hit
        source_hit -->|yes| set_both --> release
        source_hit -->|no| set_missing --> release
    end

The fine-grained locking uses a ConcurrentHashMap<String, Any> of per-key lock objects to prevent cache stampede (cache breakdown) -- multiple threads requesting the same missing key will synchronize on the same lock, so only one thread performs the expensive loadCacheValue() call.

Write Path (setCache)

mermaid
sequenceDiagram
autonumber
    participant App as Application
    participant DCC as DefaultCoherentCache
    participant CSC as ClientSideCache
    participant DC as DistributedCache
    participant EB as CacheEvictedEventBus

    App->>DCC: setCache(key, value)
    DCC->>DCC: Check if expired -> skip
    DCC->>DCC: keyConverter.toStringKey(key)
    DCC->>CSC: setCache(cacheKey, cacheValue)
    DCC->>DC: setCache(cacheKey, cacheValue)
    DCC->>EB: publish(CacheEvictedEvent)
    EB-->>EB: Notify all other instances

Evict Path

mermaid
sequenceDiagram
autonumber
    participant App as Application
    participant DCC as DefaultCoherentCache
    participant CSC as ClientSideCache
    participant DC as DistributedCache
    participant EB as CacheEvictedEventBus
    participant Other as Other Instances

    App->>DCC: evict(key)
    DCC->>DCC: keyConverter.toStringKey(key)
    DCC->>CSC: evict(cacheKey)
    DCC->>DC: evict(cacheKey)
    DCC->>EB: publish(CacheEvictedEvent)
    EB->>Other: Deliver event
    Other->>Other: onEvicted() -> evict local L2

Event-Driven Coherence

When a CacheEvictedEvent arrives, the onEvicted() handler at DefaultCoherentCache.kt:159 performs two checks:

  1. Cache name match: Ignores events for different caches.
  2. Self-published check: Ignores events published by the same clientId to avoid redundant evictions.

Only cross-instance events for the matching cache name trigger the local L2 eviction.

CoherentCacheConfiguration

CoherentCacheConfiguration is the data class that bundles all components needed to create a CoherentCache:

FieldTypeDefaultPurpose
cacheNameString(required)Cache identifier used for event bus routing
clientIdString(required)Unique client identifier for coherence filtering
keyConverterKeyConverter<K>(required)Converts typed keys to string cache keys
distributedCacheDistributedCache<V>(required)L1 shared cache
clientSideCacheClientSideCache<V>MapClientSideCache()L2 local cache
cacheSourceCacheSource<K, V>CacheSource.noOp()L0 data source
keyFilterKeyFilterNoOpKeyFilterBloom filter for key existence

Proxy System

CoCache uses JDK dynamic proxies to create cache implementations from interfaces annotated with @CoCache.

mermaid
classDiagram
    class CacheProxyFactory {
        <<interface>>
        +create(cacheMetadata: CoCacheMetadata) CACHE
    }

    class DefaultCacheProxyFactory {
        -coherentCacheFactory: CoherentCacheFactory
        -clientIdGenerator: ClientIdGenerator
        -clientSideCacheFactory: ClientSideCacheFactory
        -distributedCacheFactory: DistributedCacheFactory
        -cacheSourceFactory: CacheSourceFactory
        -keyConverterFactory: KeyConverterFactory
        +create(cacheMetadata: CoCacheMetadata) CACHE
    }

    class CoCacheProxy~DELEGATE~ {
        <<abstract>>
        +proxyInterface: Class
        +invoke(proxy, method, args) Any?
    }

    class CoCacheInvocationHandler~DELEGATE~ {
        -cacheMetadata: CoCacheMetadata
        -delegate: DELEGATE
        +invoke(proxy, method, args) Any?
    }

    class CacheDelegated~DELEGATE~ {
        <<interface>>
        +delegate: DELEGATE
    }

    class CacheMetadataCapable {
        <<interface>>
        +cacheMetadata: CoCacheMetadata
    }

    CacheProxyFactory <|.. DefaultCacheProxyFactory
    CoCacheProxy --> CacheDelegated
    CoCacheInvocationHandler --|> CoCacheProxy
    CoCacheInvocationHandler ..|> CacheDelegated
    CoCacheInvocationHandler ..|> CacheMetadataCapable

Proxy Creation Flow

In DefaultCacheProxyFactory.create():

  1. Generate a unique clientId via ClientIdGenerator.
  2. Create ClientSideCache (L2) from the factory.
  3. Create DistributedCache (L1) from the factory.
  4. Create CacheSource (L0) from the factory.
  5. Create KeyConverter from the factory.
  6. Build a CoherentCache via CoherentCacheFactory, which also registers it on the event bus.
  7. Wrap in a CoCacheInvocationHandler and create a JDK Proxy implementing the user's interface, CoherentCache, CacheDelegated, and CacheMetadataCapable.

Method Dispatch

CoCacheProxy.invoke() handles two cases:

  • Default methods on the proxy interface: Delegates to InvocationHandler.invokeDefault() for proper default method resolution.
  • All other methods: Delegates directly to the CoherentCache implementation via method.invoke(delegate, *args).

Client-Side Cache Implementations

Three implementations of ClientSideCache<V> are provided:

ImplementationFileBacking StoreKey Features
MapClientSideCacheMapClientSideCache.ktConcurrentHashMapSimplest implementation, no eviction policy, default for CoherentCacheConfiguration
GuavaClientSideCacheGuavaClientSideCache.ktGuava CacheSupports maximumSize, expireAfterWrite, expireAfterAccess, initialCapacity, concurrencyLevel. Built via @GuavaCache.toClientSideCache()
CaffeineClientSideCacheCaffeineClientSideCache.ktCaffeine CacheSame features as Guava minus concurrencyLevel. Built via @CaffeineCache.toClientSideCache()

All three implement ComputedClientSideCache<V> which extends both ClientSideCache<V> and ComputedCache<String, V>, providing automatic expired-entry eviction on read.

TTL System

The TTL system provides time-to-live computation with jitter to prevent cache avalanche (all entries expiring at once).

mermaid
graph TB
    subgraph sg_39 ["TTL Computation Pipeline"]

        ttl_input["ttl (base seconds)"]
        style ttl_input fill:#2d333b,stroke:#6d5dfc,color:#e6edf3

        amp_input["ttlAmplitude (jitter range)"]
        style amp_input fill:#2d333b,stroke:#6d5dfc,color:#e6edf3

        jitter["ComputedTtlAt.jitter(ttl, amplitude)<br>random(ttl - amp .. ttl + amp)"]
        style jitter fill:#2d333b,stroke:#6d5dfc,color:#e6edf3

        clock["CacheSecondClock.INSTANCE<br>.currentTime()"]
        style clock fill:#2d333b,stroke:#6d5dfc,color:#e6edf3

        at["ttlAt = currentTime + jitteredTtl"]
        style at fill:#2d333b,stroke:#6d5dfc,color:#e6edf3

        store["Store CacheValue(value, ttlAt)"]
        style store fill:#2d333b,stroke:#6d5dfc,color:#e6edf3

        expire_check{"CacheSecondClock.now > ttlAt?"}
        style expire_check fill:#2d333b,stroke:#6d5dfc,color:#e6edf3

        expired["isExpired = true"]
        style expired fill:#2d333b,stroke:#6d5dfc,color:#e6edf3

        valid["isExpired = false"]
        style valid fill:#2d333b,stroke:#6d5dfc,color:#e6edf3

        ttl_input --> jitter
        amp_input --> jitter
        jitter --> at
        clock --> at
        at --> store
        store --> expire_check
        expire_check -->|yes| expired
        expire_check -->|no| valid
    end

Key TTL Classes

ClassFilePurpose
ComputedTtlAtComputedTtlAt.ktComputes isExpired, isForever, expiredDuration. Uses CacheSecondClock for current time. The jitter() function randomizes TTL within amplitude bounds.
TtlConfigurationTtlConfiguration.ktInterface carrying ttl and ttlAmplitude. Implemented by CoCacheMetadata and CoherentCacheConfiguration.
CacheSecondClockCacheSecondClock.ktSingleton daemon thread that updates lastTime every second from SystemSecondClock. Avoids repeated System.currentTimeMillis() calls.
DefaultCacheValueDefaultCacheValue.ktDefault CacheValue implementation. Factory methods: forever(), ttlAt(), missingGuard().

Key Converter System

Key converters transform typed cache keys into the string keys used by L1/L2 caches.

ClassFileStrategy
KeyConverter<K>KeyConverter.ktfun interface with toStringKey(sourceKey: K): String
ToStringKeyConverter<K>ToStringKeyConverter.ktkeyPrefix + sourceKey.toString(). Default when no keyExpression is configured.
ExpKeyConverter<K>ExpKeyConverter.ktUses SpEL expression: keyPrefix + expression.getValue(sourceKey). For complex key derivation from composite objects.

Example: A UserCache with keyPrefix = "user:" and key type String produces cache keys like "user:123". With a keyExpression = "#{id}" and key type User, it evaluates the SpEL expression against the User object to extract the ID.

Key Filter (Bloom Filter)

The KeyFilter interface prevents cache penetration by checking if a key has ever been seen.

ImplementationFileBehavior
NoOpKeyFilterNoOpKeyFilter.ktAlways returns false (all keys are considered potentially valid). Default.
BloomKeyFilterBloomKeyFilter.ktWraps a Guava BloomFilter<String>. Returns true when the key is definitely not in the filter, short-circuiting the L0 lookup.

JoinCache System

SimpleJoinCache

SimpleJoinCache composes two caches:

mermaid
sequenceDiagram
autonumber
    participant App as Application
    participant SJC as SimpleJoinCache
    participant FC as FirstCache
    participant JC as JoinCache
    participant JKE as JoinKeyExtractor

    App->>SJC: getCache(key: K1)
    SJC->>FC: getCache(key: K1)
    FC-->>SJC: CacheValue<V1>

    alt First value is MissingGuard
        SJC-->>App: missingGuard()
    else First value found
        SJC->>JKE: extract(firstValue)
        JKE-->>SJC: joinKey: K2
        SJC->>JC: getCache(joinKey)
        JC-->>SJC: CacheValue<V2>?
        SJC->>SJC: min(firstTtlAt, secondTtlAt)
        SJC-->>App: CacheValue<JoinValue(V1, K2, V2)>
    end

Join Key Extraction

ClassFileStrategy
JoinKeyExtractor<V1, K2>JoinKeyExtractor.ktFunctional interface from cocache-api
ExpJoinKeyExtractor<V1>ExpJoinKeyExtractor.ktUses SpEL #{...} template expressions to extract a string join key from the first value

JoinCache Proxy

ClassFilePurpose
JoinCacheProxyFactoryJoinCacheProxyFactory.ktInterface for creating JoinCache proxies
DefaultJoinCacheProxyFactoryDefaultJoinCacheProxyFactory.ktCreates JoinCache proxies by wiring two CoherentCache instances with a JoinKeyExtractor
JoinCacheInvocationHandlerJoinCacheInvocationHandler.ktInvocationHandler for JoinCache proxy instances

CacheEvictedEventBus

The event bus distributes cache invalidation signals across instances.

ImplementationFileScope
GuavaCacheEvictedEventBusGuavaCacheEvictedEventBus.ktIn-process only. Uses Guava EventBus with @Subscribe. Suitable for single-instance deployments.
NoOpCacheEvictedEventBusNoOpCacheEvictedEventBus.ktNo-op singleton. All methods are no-ops.
RedisCacheEvictedEventBus(in cocache-spring-redis)Cross-instance via Redis Pub/Sub. See cocache-spring-redis.

Factory Interfaces

All factories follow a consistent pattern: accept CoCacheMetadata, return a component instance.

FactoryFileCreates
CacheProxyFactoryCacheProxyFactory.ktCache proxy instances from CoCacheMetadata
CoherentCacheFactoryCoherentCacheFactory.ktCoherentCache from CoherentCacheConfiguration
ClientSideCacheFactoryClientSideCacheFactory.ktClientSideCache from CoCacheMetadata
DistributedCacheFactoryDistributedCacheFactory.ktDistributedCache from CoCacheMetadata
CacheSourceFactoryCacheSourceFactory.ktCacheSource from CoCacheMetadata
KeyConverterFactoryKeyConverterFactory.ktKeyConverter from CoCacheMetadata
JoinKeyExtractorFactoryJoinKeyExtractorFactory.ktJoinKeyExtractor from JoinCacheMetadata

ClientIdGenerator

Unique client identifiers are critical for event-driven coherence -- each instance must be able to filter out its own events.

ImplementationFileStrategy
UUIDClientIdGeneratorClientIdGenerator.ktRandom UUID (no dashes)
HostClientIdGeneratorClientIdGenerator.ktcounter:processId@hostAddress (default in production)

CacheFactory

CacheFactory is a registry for looking up cache instances by name or type. The cocache-spring module provides SpringCacheFactory which delegates to Spring's BeanFactory.

Metadata Parsing

ClassFilePurpose
CoCacheMetadataCoCacheMetadata.ktParsed data class from @CoCache annotation
CoCacheMetadataParserCoCacheMetadataParser.ktParses @CoCache from KClass
JoinCacheMetadataJoinCacheMetadata.ktParsed data class from @JoinCacheable
JoinCacheMetadataParserJoinCacheMetadataParser.ktParses @JoinCacheable from KClass

Released under the Apache License 2.0.