create cache storage

This commit is contained in:
2026-08-22 12:48:09 +03:00
parent 82ad688a92
commit 9da161c095
9 changed files with 130 additions and 89 deletions
@@ -8,7 +8,7 @@ import org.koin.core.component.KoinComponent
import org.koin.core.component.get import org.koin.core.component.get
import org.koin.plugin.module.dsl.startKoin import org.koin.plugin.module.dsl.startKoin
import ru.shadowsparky.koin import ru.shadowsparky.koin
import ru.shadowsparky.vbox.backend.data.RedisCache import ru.shadowsparky.vbox.backend.domain.CacheStorage
import ru.shadowsparky.vbox.backend.presentation.configureJwt import ru.shadowsparky.vbox.backend.presentation.configureJwt
import ru.shadowsparky.vbox.backend.presentation.configureRouting import ru.shadowsparky.vbox.backend.presentation.configureRouting
import ru.shadowsparky.vbox.backend.presentation.configureSerialization import ru.shadowsparky.vbox.backend.presentation.configureSerialization
@@ -24,7 +24,7 @@ fun main(args: Array<String>) {
fun Application.module() { fun Application.module() {
monitor.subscribe(ApplicationStopping) { monitor.subscribe(ApplicationStopping) {
runBlocking { koin.getOrNull<RedisCache>()?.disconnect() } runBlocking { koin.getOrNull<CacheStorage>()?.close() }
} }
val koin = object : KoinComponent {} val koin = object : KoinComponent {}
configureSerialization() configureSerialization()
@@ -9,27 +9,93 @@ import io.lettuce.core.api.coroutines.RedisCoroutinesCommands
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.serialization.KSerializer
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import org.koin.core.annotation.Factory import org.koin.core.annotation.Single
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import ru.shadowsparky.backend.data.EnvFetcher import ru.shadowsparky.backend.data.EnvFetcher
import ru.shadowsparky.domain.DispatcherProvider import ru.shadowsparky.domain.DispatcherProvider
import ru.shadowsparky.vbox.backend.domain.CacheStorage
import java.util.concurrent.ConcurrentHashMap
import kotlin.time.Duration import kotlin.time.Duration
@OptIn(ExperimentalLettuceCoroutinesApi::class) @OptIn(ExperimentalLettuceCoroutinesApi::class)
@Factory @Single
class RedisCache( class RedisCacheStorage(
private val envFetcher: EnvFetcher, private val envFetcher: EnvFetcher,
private val dispatcherProvider: DispatcherProvider, private val dispatcherProvider: DispatcherProvider,
val json: Json private val json: Json
) { ) : CacheStorage {
private val logger = LoggerFactory.getLogger(RedisCache::class.java) private val logger = LoggerFactory.getLogger("RedisCacheStorage")
private var client: RedisClient? = null private var client: RedisClient? = null
private var connection: StatefulRedisConnection<String, String>? = null private var connection: StatefulRedisConnection<String, String>? = null
private var commands: RedisCoroutinesCommands<String, String>? = null private var commands: RedisCoroutinesCommands<String, String>? = null
private val connectionMutex = Mutex() private val connectionMutex = Mutex()
private val locks = ConcurrentHashMap<String, Mutex>()
override suspend fun <T> getOrCreate(
key: String,
serializer: KSerializer<T>,
ttl: Duration?,
createNew: suspend () -> T,
): T {
read(key, serializer)?.let { return it }
val mutex = locks.computeIfAbsent(key) { Mutex() }
return mutex.withLock {
try {
read(key, serializer)?.let { return@withLock it }
createNew().also {
write(key, it, serializer, ttl)
}
} finally {
locks.remove(key, mutex)
}
}
}
private suspend fun <T> write(
key: String,
value: T,
serializer: KSerializer<T>,
duration: Duration? = null
) {
val ttlInSeconds = duration?.inWholeSeconds
val rawValue = json.encodeToString(serializer, value)
if (ttlInSeconds != null && ttlInSeconds > 0) {
val args = SetArgs().ex(ttlInSeconds)
getCommands().set(key, rawValue, args)
} else {
getCommands().set(key, rawValue)
}
}
private suspend fun <T> read(key: String, serializer: KSerializer<T>): T? {
val raw = getCommands().get(key) ?: return null
return json.decodeFromString(serializer, raw)
}
override suspend fun delete(key: String) {
(getCommands().del(key) ?: 0L) > 0L
}
override suspend fun close() {
connectionMutex.withLock {
if (connection != null || client != null) {
logger.info("closing redis...")
try {
connection?.close()
client?.shutdown()
} catch (e: Exception) {
logger.error("unable to close redis: ${e.message}", e)
} finally {
connection = null
client = null
commands = null
}
}
}
}
private suspend fun getCommands(): RedisCoroutinesCommands<String, String> { private suspend fun getCommands(): RedisCoroutinesCommands<String, String> {
commands?.let { return it } commands?.let { return it }
@@ -56,61 +122,4 @@ class RedisCache(
} }
} }
} }
suspend inline fun <reified T> write(key: String, value: T, duration: Duration? = null) {
write(key, json.encodeToString(value), duration)
}
suspend fun write(key: String, value: String, duration: Duration? = null) {
val ttlInSeconds = duration?.inWholeSeconds
if (ttlInSeconds != null && ttlInSeconds > 0) {
val args = SetArgs().ex(ttlInSeconds)
getCommands().set(key, value, args)
} else {
getCommands().set(key, value)
}
}
suspend inline fun <reified T> transact(
key: String,
ttl: Duration? = null,
createNew: suspend () -> T
): T {
read<T>(key)?.let { return it }
val result = createNew()
write(key, result, ttl)
return result
}
suspend inline fun <reified T> read(key: String): T? {
return json.decodeFromString(readInternal(key) ?: return null)
}
suspend fun readInternal(key: String): String? {
return getCommands().get(key)
}
suspend fun delete(key: String): Boolean {
return (getCommands().del(key) ?: 0L) > 0L
}
suspend fun disconnect() {
connectionMutex.withLock {
if (connection != null || client != null) {
logger.info("Закрытие соединений с Redis...")
try {
connection?.close()
client?.shutdown()
} catch (e: Exception) {
logger.error("Ошибка при закрытии Redis: ${e.message}", e)
} finally {
connection = null
client = null
commands = null
}
logger.info("Redis успешно отключен")
}
}
}
} }
@@ -1,7 +1,8 @@
package ru.shadowsparky.vbox.backend.data.http package ru.shadowsparky.vbox.backend.data.http
import ru.shadowsparky.vbox.backend.data.FuzzyMovieSearchFilter import ru.shadowsparky.vbox.backend.data.FuzzyMovieSearchFilter
import ru.shadowsparky.vbox.backend.data.RedisCache import ru.shadowsparky.vbox.backend.domain.CacheStorage
import ru.shadowsparky.vbox.backend.domain.getOrCreate
import ru.shadowsparky.vbox.shared.domain.VideoApi import ru.shadowsparky.vbox.shared.domain.VideoApi
import ru.shadowsparky.vbox.shared.domain.model.VideoDetails import ru.shadowsparky.vbox.shared.domain.model.VideoDetails
import ru.shadowsparky.vbox.shared.domain.model.VideoLinksResponse import ru.shadowsparky.vbox.shared.domain.model.VideoLinksResponse
@@ -11,12 +12,12 @@ import kotlin.time.Duration.Companion.days
class CacheVideoApi( class CacheVideoApi(
private val wrapper: VideoApi, private val wrapper: VideoApi,
private val filter: FuzzyMovieSearchFilter, private val filter: FuzzyMovieSearchFilter,
private val redis: RedisCache private val redis: CacheStorage
) : VideoApi { ) : VideoApi {
private val ttl = 3.days private val ttl = 3.days
override suspend fun fetchNewVideos(query: String?): VideosResponse { override suspend fun fetchNewVideos(query: String?): VideosResponse {
return redis.transact("$PREFIX:videos:$query", ttl) { return redis.getOrCreate("$PREFIX:videos:$query", ttl) {
wrapper.fetchNewVideos(query).let { wrapper.fetchNewVideos(query).let {
if (query != null) { if (query != null) {
it.copy(items = filter.filter(it.items, query)) it.copy(items = filter.filter(it.items, query))
@@ -28,11 +29,11 @@ class CacheVideoApi(
} }
override suspend fun fetchDetails(id: Long): VideoDetails { override suspend fun fetchDetails(id: Long): VideoDetails {
return redis.transact("$PREFIX:details:$id", ttl) { wrapper.fetchDetails(id) } return redis.getOrCreate("$PREFIX:details:$id", ttl) { wrapper.fetchDetails(id) }
} }
override suspend fun fetchVideoLinks(id: Long, seasonId: Long?): VideoLinksResponse { override suspend fun fetchVideoLinks(id: Long, seasonId: Long?): VideoLinksResponse {
return redis.transact("$PREFIX:links:$id:$seasonId", ttl) { return redis.getOrCreate("$PREFIX:links:$id:$seasonId", ttl) {
wrapper.fetchVideoLinks(id, seasonId) wrapper.fetchVideoLinks(id, seasonId)
} }
} }
@@ -3,14 +3,15 @@ package ru.shadowsparky.vbox.backend.data.recent
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flow
import ru.shadowsparky.vbox.backend.data.RedisCache import ru.shadowsparky.vbox.backend.domain.CacheStorage
import ru.shadowsparky.vbox.backend.domain.getOrCreate
import ru.shadowsparky.vbox.shared.domain.RecentlyWatchedRepository import ru.shadowsparky.vbox.shared.domain.RecentlyWatchedRepository
import ru.shadowsparky.vbox.shared.domain.model.RecentlyWatchedInfo import ru.shadowsparky.vbox.shared.domain.model.RecentlyWatchedInfo
import kotlin.time.Duration.Companion.days import kotlin.time.Duration.Companion.days
class CacheRecentlyWatchedRepository( class CacheRecentlyWatchedRepository(
private val wrapper: RecentlyWatchedRepository, private val wrapper: RecentlyWatchedRepository,
private val redis: RedisCache, private val redis: CacheStorage,
userId: Long userId: Long
) : RecentlyWatchedRepository { ) : RecentlyWatchedRepository {
private val prefix = "recently_watched:$userId" private val prefix = "recently_watched:$userId"
@@ -38,7 +39,7 @@ class CacheRecentlyWatchedRepository(
): Flow<List<RecentlyWatchedInfo>> { ): Flow<List<RecentlyWatchedInfo>> {
return flow { return flow {
emit( emit(
redis.transact( redis.getOrCreate(
"$prefix:$movieId:$seasonId", "$prefix:$movieId:$seasonId",
7.days 7.days
) { wrapper.queryRecentlyWatched(movieId, seasonId).first() } ) { wrapper.queryRecentlyWatched(movieId, seasonId).first() }
@@ -47,7 +48,7 @@ class CacheRecentlyWatchedRepository(
} }
override suspend fun getAllRecentlyWatched(): List<RecentlyWatchedInfo> { override suspend fun getAllRecentlyWatched(): List<RecentlyWatchedInfo> {
return redis.transact( return redis.getOrCreate(
prefix, prefix,
7.days 7.days
) { wrapper.getAllRecentlyWatched() } ) { wrapper.getAllRecentlyWatched() }
@@ -3,14 +3,15 @@ package ru.shadowsparky.vbox.backend.data.saved
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flow
import ru.shadowsparky.vbox.backend.data.RedisCache import ru.shadowsparky.vbox.backend.domain.CacheStorage
import ru.shadowsparky.vbox.backend.domain.getOrCreate
import ru.shadowsparky.vbox.shared.domain.SavedMovieRepository import ru.shadowsparky.vbox.shared.domain.SavedMovieRepository
import ru.shadowsparky.vbox.shared.domain.model.VideoDetails import ru.shadowsparky.vbox.shared.domain.model.VideoDetails
import kotlin.time.Duration.Companion.days import kotlin.time.Duration.Companion.days
class CacheSavedMovieRepository( class CacheSavedMovieRepository(
private val wrapper: SavedMovieRepository, private val wrapper: SavedMovieRepository,
private val redis: RedisCache, private val redis: CacheStorage,
userId: Long userId: Long
) : SavedMovieRepository { ) : SavedMovieRepository {
private val ttl = 7.days private val ttl = 7.days
@@ -27,13 +28,13 @@ class CacheSavedMovieRepository(
override fun getAll(): Flow<List<VideoDetails>> { override fun getAll(): Flow<List<VideoDetails>> {
return flow { return flow {
emit(redis.transact(prefix, ttl) { wrapper.getAll().first() }) emit(redis.getOrCreate(prefix, ttl) { wrapper.getAll().first() })
} }
} }
override fun isSaved(id: Long): Flow<Boolean> { override fun isSaved(id: Long): Flow<Boolean> {
return flow { return flow {
emit(redis.transact("$prefix:$id") { wrapper.isSaved(id).first() }) emit(redis.getOrCreate("$prefix:$id", ttl) { wrapper.isSaved(id).first() })
} }
} }
@@ -3,9 +3,9 @@ package ru.shadowsparky.vbox.backend.di
import org.koin.core.annotation.Factory import org.koin.core.annotation.Factory
import org.koin.core.annotation.Module import org.koin.core.annotation.Module
import ru.shadowsparky.vbox.backend.data.FuzzyMovieSearchFilter import ru.shadowsparky.vbox.backend.data.FuzzyMovieSearchFilter
import ru.shadowsparky.vbox.backend.data.RedisCache
import ru.shadowsparky.vbox.backend.data.http.ExternalBackendApi
import ru.shadowsparky.vbox.backend.data.http.CacheVideoApi import ru.shadowsparky.vbox.backend.data.http.CacheVideoApi
import ru.shadowsparky.vbox.backend.data.http.ExternalBackendApi
import ru.shadowsparky.vbox.backend.domain.CacheStorage
import ru.shadowsparky.vbox.shared.domain.VideoApi import ru.shadowsparky.vbox.shared.domain.VideoApi
@Module @Module
@@ -15,8 +15,8 @@ class HttpModule {
fun provideVideoApi( fun provideVideoApi(
impl: ExternalBackendApi, impl: ExternalBackendApi,
filter: FuzzyMovieSearchFilter, filter: FuzzyMovieSearchFilter,
redisCache: RedisCache cacheStorage: CacheStorage
): VideoApi { ): VideoApi {
return CacheVideoApi(impl, filter, redisCache) return CacheVideoApi(impl, filter, cacheStorage)
} }
} }
@@ -2,9 +2,9 @@ package ru.shadowsparky.vbox.backend.di.factory
import org.koin.core.annotation.Factory import org.koin.core.annotation.Factory
import ru.shadowsparky.vbox.backend.AppDatabase import ru.shadowsparky.vbox.backend.AppDatabase
import ru.shadowsparky.vbox.backend.data.RedisCache
import ru.shadowsparky.vbox.backend.data.recent.BackendRecentlyWatchedRepository import ru.shadowsparky.vbox.backend.data.recent.BackendRecentlyWatchedRepository
import ru.shadowsparky.vbox.backend.data.recent.CacheRecentlyWatchedRepository import ru.shadowsparky.vbox.backend.data.recent.CacheRecentlyWatchedRepository
import ru.shadowsparky.vbox.backend.domain.CacheStorage
import ru.shadowsparky.vbox.shared.di.factory.DispatcherProvider import ru.shadowsparky.vbox.shared.di.factory.DispatcherProvider
import ru.shadowsparky.vbox.shared.domain.RecentlyWatchedRepository import ru.shadowsparky.vbox.shared.domain.RecentlyWatchedRepository
import ru.shadowsparky.vbox.shared.domain.RemoteEventHandler import ru.shadowsparky.vbox.shared.domain.RemoteEventHandler
@@ -14,13 +14,13 @@ class RecentlyWatchedRepositoryFactory(
private val db: AppDatabase, private val db: AppDatabase,
private val dispatcherProvider: DispatcherProvider, private val dispatcherProvider: DispatcherProvider,
private val remoteEventHandler: RemoteEventHandler, private val remoteEventHandler: RemoteEventHandler,
private val redisCache: RedisCache private val cacheStorage: CacheStorage
) { ) {
fun create(userId: Long): RecentlyWatchedRepository { fun create(userId: Long): RecentlyWatchedRepository {
val impl = BackendRecentlyWatchedRepository(db, userId, dispatcherProvider, remoteEventHandler) val impl = BackendRecentlyWatchedRepository(db, userId, dispatcherProvider, remoteEventHandler)
return CacheRecentlyWatchedRepository( return CacheRecentlyWatchedRepository(
impl, impl,
redisCache, cacheStorage,
userId userId
) )
} }
@@ -4,9 +4,9 @@ import org.koin.core.annotation.Factory
import ru.shadowsparky.backend.data.Logger import ru.shadowsparky.backend.data.Logger
import ru.shadowsparky.vbox.backend.AppDatabase import ru.shadowsparky.vbox.backend.AppDatabase
import ru.shadowsparky.vbox.backend.data.BackendRemoteEventHandler import ru.shadowsparky.vbox.backend.data.BackendRemoteEventHandler
import ru.shadowsparky.vbox.backend.data.RedisCache
import ru.shadowsparky.vbox.backend.data.saved.BackendSavedMovieRepository import ru.shadowsparky.vbox.backend.data.saved.BackendSavedMovieRepository
import ru.shadowsparky.vbox.backend.data.saved.CacheSavedMovieRepository import ru.shadowsparky.vbox.backend.data.saved.CacheSavedMovieRepository
import ru.shadowsparky.vbox.backend.domain.CacheStorage
import ru.shadowsparky.vbox.shared.di.factory.DispatcherProvider import ru.shadowsparky.vbox.shared.di.factory.DispatcherProvider
import ru.shadowsparky.vbox.shared.domain.SavedMovieRepository import ru.shadowsparky.vbox.shared.domain.SavedMovieRepository
@@ -16,10 +16,10 @@ class SavedMovieRepositoryFactory(
private val dispatcherProvider: DispatcherProvider, private val dispatcherProvider: DispatcherProvider,
private val logger: Logger, private val logger: Logger,
private val eventHandler: BackendRemoteEventHandler, private val eventHandler: BackendRemoteEventHandler,
private val redisCache: RedisCache private val cacheStorage: CacheStorage
) { ) {
fun create(userId: Long): SavedMovieRepository { fun create(userId: Long): SavedMovieRepository {
val impl = BackendSavedMovieRepository(db, userId, dispatcherProvider, logger, eventHandler) val impl = BackendSavedMovieRepository(db, userId, dispatcherProvider, logger, eventHandler)
return CacheSavedMovieRepository(impl, redisCache, userId) return CacheSavedMovieRepository(impl, cacheStorage, userId)
} }
} }
@@ -0,0 +1,29 @@
package ru.shadowsparky.vbox.backend.domain
import kotlinx.serialization.KSerializer
import kotlinx.serialization.serializer
import kotlin.time.Duration
interface CacheStorage {
suspend fun <T> getOrCreate(
key: String,
serializer: KSerializer<T>,
ttl: Duration? = null,
createNew: suspend () -> T,
): T
suspend fun delete(key: String)
suspend fun close()
}
suspend inline fun <reified T> CacheStorage.getOrCreate(
key: String,
ttl: Duration,
noinline createNew: suspend () -> T,
): T {
return getOrCreate(
key = key,
serializer = serializer<T>(),
ttl = ttl,
createNew = createNew
)
}