Skip to content

Commit 3db20f0

Browse files
committed
feat(core): Implement robust caching system with Room and Memory Cache
This commit introduces a tiered caching architecture (memory + database) to improve performance and reduce API calls. It adds a `CacheManager` to handle data persistence with TTL support and integrates it into the home repository. - **feat(core/data)**: Added `CacheManager` with dual memory/database storage and TTL management. - **feat(core/data)**: Introduced `CacheDao` and `CacheEntryEntity` using Room for persistent JSON caching. - **feat(home)**: Integrated `CacheManager` into `HomeRepositoryImpl` to cache trending, hot release, and popular repository lists. - **refactor(domain)**: Marked several domain models (`GithubRelease`, `GithubAsset`, `UserProfile`, etc.) as `@Serializable` to support cache serialization. - **chore(db)**: Incremented database version to 4 and added `MIGRATION_3_4` to create the `cache_entries` table. - **chore(build)**: Added `kotlinx-datetime` dependency to the core data module.
1 parent f695f76 commit 3db20f0

16 files changed

Lines changed: 285 additions & 45 deletions

File tree

core/data/build.gradle.kts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ kotlin {
1919

2020
implementation(libs.datastore)
2121
implementation(libs.datastore.preferences)
22+
23+
implementation(libs.kotlinx.datetime)
2224
}
2325
}
2426

core/data/src/androidMain/kotlin/zed/rainxch/core/data/local/db/initDatabase.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import androidx.room.Room
55
import kotlinx.coroutines.Dispatchers
66
import zed.rainxch.core.data.local.db.migrations.MIGRATION_1_2
77
import zed.rainxch.core.data.local.db.migrations.MIGRATION_2_3
8+
import zed.rainxch.core.data.local.db.migrations.MIGRATION_3_4
89

910
fun initDatabase(context: Context): AppDatabase {
1011
val appContext = context.applicationContext
@@ -18,6 +19,7 @@ fun initDatabase(context: Context): AppDatabase {
1819
.addMigrations(
1920
MIGRATION_1_2,
2021
MIGRATION_2_3,
22+
MIGRATION_3_4,
2123
)
2224
.build()
2325
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
package zed.rainxch.core.data.local.db.migrations
2+
3+
import androidx.room.migration.Migration
4+
import androidx.sqlite.db.SupportSQLiteDatabase
5+
6+
val MIGRATION_3_4 = object : Migration(3, 4) {
7+
override fun migrate(db: SupportSQLiteDatabase) {
8+
db.execSQL("""
9+
CREATE TABLE IF NOT EXISTS cache_entries (
10+
`key` TEXT NOT NULL,
11+
jsonData TEXT NOT NULL,
12+
cachedAt INTEGER NOT NULL,
13+
expiresAt INTEGER NOT NULL,
14+
PRIMARY KEY(`key`)
15+
)
16+
""".trimIndent())
17+
}
18+
}
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
package zed.rainxch.core.data.cache
2+
3+
import kotlinx.serialization.json.Json
4+
import kotlinx.serialization.serializer
5+
import zed.rainxch.core.data.local.db.dao.CacheDao
6+
import zed.rainxch.core.data.local.db.entities.CacheEntryEntity
7+
import kotlin.time.Clock
8+
import kotlin.time.Duration.Companion.hours
9+
10+
object CacheTtl {
11+
val HOME_REPOS = 3.hours.inWholeMilliseconds
12+
val REPO_DETAILS = 6.hours.inWholeMilliseconds
13+
val RELEASES = 6.hours.inWholeMilliseconds
14+
val README = 12.hours.inWholeMilliseconds
15+
val USER_PROFILE = 6.hours.inWholeMilliseconds
16+
val SEARCH_RESULTS = 1.hours.inWholeMilliseconds
17+
val REPO_STATS = 6.hours.inWholeMilliseconds
18+
}
19+
20+
class CacheManager(
21+
val cacheDao: CacheDao
22+
) {
23+
val json = Json {
24+
ignoreUnknownKeys = true
25+
isLenient = true
26+
encodeDefaults = true
27+
}
28+
29+
val memoryCache = HashMap<String, Pair<Long, String>>()
30+
31+
fun now(): Long = Clock.System.now().toEpochMilliseconds()
32+
33+
suspend inline fun <reified T> get(key: String): T? {
34+
val currentTime = now()
35+
36+
memoryCache[key]?.let { (expiresAt, jsonData) ->
37+
if (expiresAt > currentTime) {
38+
return try {
39+
json.decodeFromString(serializer<T>(), jsonData)
40+
} catch (_: Exception) {
41+
memoryCache.remove(key)
42+
null
43+
}
44+
} else {
45+
memoryCache.remove(key)
46+
}
47+
}
48+
49+
val entry = cacheDao.getValid(key, currentTime) ?: return null
50+
memoryCache[key] = entry.expiresAt to entry.jsonData
51+
52+
return try {
53+
json.decodeFromString(serializer<T>(), entry.jsonData)
54+
} catch (_: Exception) {
55+
cacheDao.delete(key)
56+
memoryCache.remove(key)
57+
null
58+
}
59+
}
60+
61+
suspend inline fun <reified T> getStale(key: String): T? {
62+
val entry = cacheDao.getAny(key) ?: return null
63+
return try {
64+
json.decodeFromString(serializer<T>(), entry.jsonData)
65+
} catch (_: Exception) {
66+
null
67+
}
68+
}
69+
70+
suspend inline fun <reified T> put(key: String, value: T, ttlMillis: Long) {
71+
val currentTime = now()
72+
val jsonData = json.encodeToString(serializer<T>(), value)
73+
val expiresAt = currentTime + ttlMillis
74+
75+
memoryCache[key] = expiresAt to jsonData
76+
77+
cacheDao.put(
78+
CacheEntryEntity(
79+
key = key,
80+
jsonData = jsonData,
81+
cachedAt = currentTime,
82+
expiresAt = expiresAt
83+
)
84+
)
85+
}
86+
87+
suspend fun invalidate(key: String) {
88+
memoryCache.remove(key)
89+
cacheDao.delete(key)
90+
}
91+
92+
suspend fun invalidateByPrefix(prefix: String) {
93+
memoryCache.keys.removeAll { it.startsWith(prefix) }
94+
cacheDao.deleteByPrefix(prefix)
95+
}
96+
97+
suspend fun cleanupExpired() {
98+
val currentTime = now()
99+
memoryCache.entries.removeAll { it.value.first <= currentTime }
100+
cacheDao.deleteExpired(currentTime)
101+
}
102+
}

core/data/src/commonMain/kotlin/zed/rainxch/core/data/di/SharedModule.kt

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,11 @@ import kotlinx.coroutines.flow.first
88
import kotlinx.coroutines.runBlocking
99
import kotlinx.coroutines.withTimeout
1010
import org.koin.dsl.module
11+
import zed.rainxch.core.data.cache.CacheManager
1112
import zed.rainxch.core.data.data_source.TokenStore
1213
import zed.rainxch.core.data.data_source.impl.DefaultTokenStore
1314
import zed.rainxch.core.data.local.db.AppDatabase
15+
import zed.rainxch.core.data.local.db.dao.CacheDao
1416
import zed.rainxch.core.data.local.db.dao.FavoriteRepoDao
1517
import zed.rainxch.core.data.local.db.dao.InstalledAppDao
1618
import zed.rainxch.core.data.local.db.dao.StarredRepoDao
@@ -104,6 +106,10 @@ val coreModule = module {
104106
logger = get()
105107
)
106108
}
109+
110+
single<CacheManager> {
111+
CacheManager(cacheDao = get())
112+
}
107113
}
108114

109115
val networkModule = module {
@@ -175,4 +181,8 @@ val databaseModule = module {
175181
single<UpdateHistoryDao> {
176182
get<AppDatabase>().updateHistoryDao
177183
}
184+
185+
single<CacheDao> {
186+
get<AppDatabase>().cacheDao
187+
}
178188
}

core/data/src/commonMain/kotlin/zed/rainxch/core/data/local/db/AppDatabase.kt

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,12 @@ package zed.rainxch.core.data.local.db
22

33
import androidx.room.Database
44
import androidx.room.RoomDatabase
5+
import zed.rainxch.core.data.local.db.dao.CacheDao
56
import zed.rainxch.core.data.local.db.dao.FavoriteRepoDao
67
import zed.rainxch.core.data.local.db.dao.InstalledAppDao
78
import zed.rainxch.core.data.local.db.dao.StarredRepoDao
89
import zed.rainxch.core.data.local.db.dao.UpdateHistoryDao
10+
import zed.rainxch.core.data.local.db.entities.CacheEntryEntity
911
import zed.rainxch.core.data.local.db.entities.FavoriteRepoEntity
1012
import zed.rainxch.core.data.local.db.entities.InstalledAppEntity
1113
import zed.rainxch.core.data.local.db.entities.StarredRepositoryEntity
@@ -17,13 +19,15 @@ import zed.rainxch.core.data.local.db.entities.UpdateHistoryEntity
1719
FavoriteRepoEntity::class,
1820
UpdateHistoryEntity::class,
1921
StarredRepositoryEntity::class,
22+
CacheEntryEntity::class,
2023
],
21-
version = 3,
24+
version = 4,
2225
exportSchema = true
2326
)
2427
abstract class AppDatabase : RoomDatabase() {
2528
abstract val installedAppDao: InstalledAppDao
2629
abstract val favoriteRepoDao: FavoriteRepoDao
2730
abstract val updateHistoryDao: UpdateHistoryDao
2831
abstract val starredReposDao: StarredRepoDao
29-
}
32+
abstract val cacheDao: CacheDao
33+
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
package zed.rainxch.core.data.local.db.dao
2+
3+
import androidx.room.Dao
4+
import androidx.room.Insert
5+
import androidx.room.OnConflictStrategy
6+
import androidx.room.Query
7+
import zed.rainxch.core.data.local.db.entities.CacheEntryEntity
8+
9+
@Dao
10+
interface CacheDao {
11+
@Query("SELECT * FROM cache_entries WHERE `key` = :key AND expiresAt > :now LIMIT 1")
12+
suspend fun getValid(key: String, now: Long): CacheEntryEntity?
13+
14+
@Query("SELECT * FROM cache_entries WHERE `key` = :key LIMIT 1")
15+
suspend fun getAny(key: String): CacheEntryEntity?
16+
17+
@Query("SELECT * FROM cache_entries WHERE `key` LIKE :prefix || '%' AND expiresAt > :now")
18+
suspend fun getValidByPrefix(prefix: String, now: Long): List<CacheEntryEntity>
19+
20+
@Insert(onConflict = OnConflictStrategy.REPLACE)
21+
suspend fun put(entry: CacheEntryEntity)
22+
23+
@Query("DELETE FROM cache_entries WHERE `key` = :key")
24+
suspend fun delete(key: String)
25+
26+
@Query("DELETE FROM cache_entries WHERE `key` LIKE :prefix || '%'")
27+
suspend fun deleteByPrefix(prefix: String)
28+
29+
@Query("DELETE FROM cache_entries WHERE expiresAt <= :now")
30+
suspend fun deleteExpired(now: Long)
31+
32+
@Query("DELETE FROM cache_entries")
33+
suspend fun deleteAll()
34+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
package zed.rainxch.core.data.local.db.entities
2+
3+
import androidx.room.Entity
4+
import androidx.room.PrimaryKey
5+
6+
@Entity(tableName = "cache_entries")
7+
data class CacheEntryEntity(
8+
@PrimaryKey
9+
val key: String,
10+
val jsonData: String,
11+
val cachedAt: Long,
12+
val expiresAt: Long
13+
)

core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/GithubAsset.kt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
package zed.rainxch.core.domain.model
22

3+
import kotlinx.serialization.Serializable
4+
5+
@Serializable
36
data class GithubAsset(
47
val id: Long,
58
val name: String,

core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/GithubRelease.kt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
package zed.rainxch.core.domain.model
22

3+
import kotlinx.serialization.Serializable
4+
5+
@Serializable
36
data class GithubRelease(
47
val id: Long,
58
val tagName: String,

0 commit comments

Comments
 (0)