avdar

Type-safe caching with CBOR-encoded models, stale-while-revalidate serving, L1 in-memory LRU plus L2 persistent backing, request deduplication, cache-control parsing and configurable TTL policies.

Android JVMJVMKotlin/Native
GitHub stars2
Authorsnomadsim
Open issues0
LicenseApache License 2.0
Creation dateabout 2 months ago

Last activityabout 2 months ago
Latest release2.0.1 (about 2 months ago)

Avdar

Avdar is a Kotlin Multiplatform (KMP) caching library that provides a type-safe API, a small DSL for cache configuration, and built-in stale-while-revalidate (SWR) behavior. It offers L1 (in-memory) and L2 (persistent) caching, request deduplication, and cache-control integration to keep data fast and resilient across platforms.

There is a Mongolian saying: "It's better to look in the chest than ask around" (Айлаас эрэхээр авдраа уудал). That saying captures the essence of this library: looking in your own "chest" (cache) before making a network request.

Highlights

  • Type-safe caching with @Serializable models and CBOR encoding via kotlinx.serialization.
  • Stale-while-revalidate to serve cached data immediately while refreshing in the background.
  • L1 + L2 caching with an in-memory LRU store layered on a persistent store you provide.
  • Request deduplication so concurrent fetches for the same key share one inflight request.
  • Cache-control integration with helpers for HTTP headers.
  • KMP-ready for JVM, Android, iOS, and Linux targets.

Platform Targets

  • JVM
  • Android
  • iOS (x64, arm64, simulator arm64)
  • Linux (x64)

Concepts

Cache Entry Lifecycle

Each cache entry has two timestamps:

  • freshUntil: before this time, the entry is fresh and returned immediately.
  • stalesAt: after freshUntil but before stalesAt, the entry is stale and can be served while a background refresh runs (SWR window).
  • expired: after stalesAt, the entry is expired and must be fetched again.

Stores

Avdar layers two cache tiers:

  • L1: LruMemoryStore for fast in-memory access with LRU eviction.
  • L2: a persistent Store implementation that you provide (for example, Room, SQLDelight, or file-based storage).

You choose between public and private stores per entity. Private stores are intended for sensitive data (often encrypted and/or with hashed keys).

Policies

Entity cache policies (TTL and SWR durations) can be persisted via PolicyStore. Call persistPolicies() after registration to store policy metadata for later inspection or tooling.

Installation

dependencies {
    implementation("im.nmds.avdar:avdar:1.0.0")
}

Quick Start

@Serializable
data class Region(val id: String, val name: String)

val avdar = Avdar(
    stores = AvdarStores(
        publicStore = publicStoreImpl,
        privateStore = privateStoreImpl,
        policyStore = policyStoreImpl,
        memoryStore = LruMemoryStore()
    )
)

avdar.entity(Region::class) {
    store = StoreType.Public
    ttl = 15.minutes
    swr = 7.days
}

// Persist policies after registering all entities
avdar.persistPolicies()

// Fetch with SWR
val region = avdar.fetch<Region>(key = "region-123") {
    val response = api.getRegion("region-123")
    putAndReturn(cacheControl = response.cacheControl) { response.value }
}

API Overview

Register Entities

avdar.entity(User::class) {
    store = StoreType.Private
    ttl = 10.minutes
    swr = 1.hours
    typeName = "users"
}

Fetch

val user = avdar.fetch<User>(key = "user-42") {
    val response = api.getUser("user-42")
    putAndReturn(cacheControl = response.cacheControl) { response.value }
}

Fetch order:

  1. Fresh L1 cache
  2. Fresh L2 cache (promoted to L1)
  3. Stale cache (SWR) with background refresh
  4. Network fetch (deduplicated across callers)

Force Refresh

val fresh = avdar.fetch<User>(key = "user-42", forceRefresh = true) {
    val response = api.getUser("user-42")
    putAndReturn { response.value }
}

forceRefresh = true bypasses freshness checks but still deduplicates concurrent requests and falls back to stale data on error.

Refetch

val fresh = avdar.refetch<User>(key = "user-42") {
    val response = api.getUser("user-42")
    putAndReturn { response.value }
}

refetch is a dedicated helper that always executes the fetch block and updates the cache.

Direct Cache Access

val cached = avdar.get<User>("user-42")
val exists = avdar.exists<User>("user-42")
val fresh = avdar.isFresh<User>("user-42")

Put and Invalidate

avdar.put("user-42", user)

avdar.invalidate<User>("user-42")

Cache-Control Integration

Avdar includes a small helper to parse HTTP cache directives and ETags:

val cacheControl = CacheControl.parse(
    headerValue = response.headers["Cache-Control"],
    etag = response.headers["ETag"]
)

Use cacheControl.maxAge, cacheControl.staleWhileRevalidate, cacheControl.noStore, and cacheControl.etag inside putAndReturn or put to override default TTL/SWR behavior.

Implementing Stores

You provide two implementations:

  • Public Store: normal cache data.
  • Private Store: sensitive data (often encrypted at rest).

Each store implements the Store interface:

interface Store {
    suspend fun getEntry(type: String, key: String): CacheEntry?
    suspend fun setEntry(entry: CacheEntry)
    suspend fun deleteEntry(type: String, key: String)
    suspend fun deleteStaleEntries(currentTimeMillis: Long)
    suspend fun deleteAllEntries()
}

A PolicyStore implementation is optional but recommended for persistable TTL/SWR metadata.

LruMemoryStore

LruMemoryStore provides an in-memory L1 cache with LRU eviction. The default target size is 10 MB, and you can adjust it:

val memoryStore = LruMemoryStore(targetSize = 25L * 1024 * 1024) // 25 MB

Logging

Avdar is silent by default. To enable logging, pass an AvdarLogger implementation to the constructor:

val avdar = Avdar(
    stores = AvdarStores(
        publicStore = publicStoreImpl,
        privateStore = privateStoreImpl,
        policyStore = policyStoreImpl,
        memoryStore = LruMemoryStore()
    ),
    logger = object : AvdarLogger {
        override fun d(message: () -> String) = println("[DEBUG] ${message()}")
        override fun i(message: () -> String) = println("[INFO] ${message()}")
        override fun w(message: () -> String) = println("[WARN] ${message()}")
        override fun w(throwable: Throwable, message: () -> String) = println("[WARN] ${message()}: $throwable")
        override fun e(message: () -> String) = println("[ERROR] ${message()}")
        override fun e(throwable: Throwable, message: () -> String) = println("[ERROR] ${message()}: $throwable")
    }
)

You can integrate with any logging framework (Kermit, SLF4J, Logback, etc.) by implementing AvdarLogger.

Serialization Requirements

Entities must be serializable via kotlinx.serialization (CBOR). Make sure your models are annotated with @Serializable and that you include the serialization plugin.

Testing

./gradlew :library:jvmTest

License

Apache-2.0

Android JVMJVMKotlin/Native
GitHub stars2
Authorsnomadsim
Open issues0
LicenseApache License 2.0
Creation dateabout 2 months ago

Last activityabout 2 months ago
Latest release2.0.1 (about 2 months ago)

Avdar

Avdar is a Kotlin Multiplatform (KMP) caching library that provides a type-safe API, a small DSL for cache configuration, and built-in stale-while-revalidate (SWR) behavior. It offers L1 (in-memory) and L2 (persistent) caching, request deduplication, and cache-control integration to keep data fast and resilient across platforms.

There is a Mongolian saying: "It's better to look in the chest than ask around" (Айлаас эрэхээр авдраа уудал). That saying captures the essence of this library: looking in your own "chest" (cache) before making a network request.

Highlights

  • Type-safe caching with @Serializable models and CBOR encoding via kotlinx.serialization.
  • Stale-while-revalidate to serve cached data immediately while refreshing in the background.
  • L1 + L2 caching with an in-memory LRU store layered on a persistent store you provide.
  • Request deduplication so concurrent fetches for the same key share one inflight request.
  • Cache-control integration with helpers for HTTP headers.
  • KMP-ready for JVM, Android, iOS, and Linux targets.

Platform Targets

  • JVM
  • Android
  • iOS (x64, arm64, simulator arm64)
  • Linux (x64)

Concepts

Cache Entry Lifecycle

Each cache entry has two timestamps:

  • freshUntil: before this time, the entry is fresh and returned immediately.
  • stalesAt: after freshUntil but before stalesAt, the entry is stale and can be served while a background refresh runs (SWR window).
  • expired: after stalesAt, the entry is expired and must be fetched again.

Stores

Avdar layers two cache tiers:

  • L1: LruMemoryStore for fast in-memory access with LRU eviction.
  • L2: a persistent Store implementation that you provide (for example, Room, SQLDelight, or file-based storage).

You choose between public and private stores per entity. Private stores are intended for sensitive data (often encrypted and/or with hashed keys).

Policies

Entity cache policies (TTL and SWR durations) can be persisted via PolicyStore. Call persistPolicies() after registration to store policy metadata for later inspection or tooling.

Installation

dependencies {
    implementation("im.nmds.avdar:avdar:1.0.0")
}

Quick Start

@Serializable
data class Region(val id: String, val name: String)

val avdar = Avdar(
    stores = AvdarStores(
        publicStore = publicStoreImpl,
        privateStore = privateStoreImpl,
        policyStore = policyStoreImpl,
        memoryStore = LruMemoryStore()
    )
)

avdar.entity(Region::class) {
    store = StoreType.Public
    ttl = 15.minutes
    swr = 7.days
}

// Persist policies after registering all entities
avdar.persistPolicies()

// Fetch with SWR
val region = avdar.fetch<Region>(key = "region-123") {
    val response = api.getRegion("region-123")
    putAndReturn(cacheControl = response.cacheControl) { response.value }
}

API Overview

Register Entities

avdar.entity(User::class) {
    store = StoreType.Private
    ttl = 10.minutes
    swr = 1.hours
    typeName = "users"
}

Fetch

val user = avdar.fetch<User>(key = "user-42") {
    val response = api.getUser("user-42")
    putAndReturn(cacheControl = response.cacheControl) { response.value }
}

Fetch order:

  1. Fresh L1 cache
  2. Fresh L2 cache (promoted to L1)
  3. Stale cache (SWR) with background refresh
  4. Network fetch (deduplicated across callers)

Force Refresh

val fresh = avdar.fetch<User>(key = "user-42", forceRefresh = true) {
    val response = api.getUser("user-42")
    putAndReturn { response.value }
}

forceRefresh = true bypasses freshness checks but still deduplicates concurrent requests and falls back to stale data on error.

Refetch

val fresh = avdar.refetch<User>(key = "user-42") {
    val response = api.getUser("user-42")
    putAndReturn { response.value }
}

refetch is a dedicated helper that always executes the fetch block and updates the cache.

Direct Cache Access

val cached = avdar.get<User>("user-42")
val exists = avdar.exists<User>("user-42")
val fresh = avdar.isFresh<User>("user-42")

Put and Invalidate

avdar.put("user-42", user)

avdar.invalidate<User>("user-42")

Cache-Control Integration

Avdar includes a small helper to parse HTTP cache directives and ETags:

val cacheControl = CacheControl.parse(
    headerValue = response.headers["Cache-Control"],
    etag = response.headers["ETag"]
)

Use cacheControl.maxAge, cacheControl.staleWhileRevalidate, cacheControl.noStore, and cacheControl.etag inside putAndReturn or put to override default TTL/SWR behavior.

Implementing Stores

You provide two implementations:

  • Public Store: normal cache data.
  • Private Store: sensitive data (often encrypted at rest).

Each store implements the Store interface:

interface Store {
    suspend fun getEntry(type: String, key: String): CacheEntry?
    suspend fun setEntry(entry: CacheEntry)
    suspend fun deleteEntry(type: String, key: String)
    suspend fun deleteStaleEntries(currentTimeMillis: Long)
    suspend fun deleteAllEntries()
}

A PolicyStore implementation is optional but recommended for persistable TTL/SWR metadata.

LruMemoryStore

LruMemoryStore provides an in-memory L1 cache with LRU eviction. The default target size is 10 MB, and you can adjust it:

val memoryStore = LruMemoryStore(targetSize = 25L * 1024 * 1024) // 25 MB

Logging

Avdar is silent by default. To enable logging, pass an AvdarLogger implementation to the constructor:

val avdar = Avdar(
    stores = AvdarStores(
        publicStore = publicStoreImpl,
        privateStore = privateStoreImpl,
        policyStore = policyStoreImpl,
        memoryStore = LruMemoryStore()
    ),
    logger = object : AvdarLogger {
        override fun d(message: () -> String) = println("[DEBUG] ${message()}")
        override fun i(message: () -> String) = println("[INFO] ${message()}")
        override fun w(message: () -> String) = println("[WARN] ${message()}")
        override fun w(throwable: Throwable, message: () -> String) = println("[WARN] ${message()}: $throwable")
        override fun e(message: () -> String) = println("[ERROR] ${message()}")
        override fun e(throwable: Throwable, message: () -> String) = println("[ERROR] ${message()}: $throwable")
    }
)

You can integrate with any logging framework (Kermit, SLF4J, Logback, etc.) by implementing AvdarLogger.

Serialization Requirements

Entities must be serializable via kotlinx.serialization (CBOR). Make sure your models are annotated with @Serializable and that you include the serialization plugin.

Testing

./gradlew :library:jvmTest

License

Apache-2.0