Skip to content

nomadsim/avdar

Folders and files

NameName
Last commit message
Last commit date

Latest commit

Β 

History

44 Commits
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 

Repository files navigation

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

About

Structured caching with SWR for everyone πŸš€

Resources

License

Stars

Watchers

Forks

Packages

 
 
 

Contributors

Languages