From 9da161c095ddcd62b54813eabd8d10e32775abe1 Mon Sep 17 00:00:00 2001 From: Eugene Date: Sat, 22 Aug 2026 12:48:09 +0300 Subject: [PATCH] create cache storage --- .../ru/shadowsparky/vbox/backend/Main.kt | 4 +- .../{RedisCache.kt => RedisCacheStorage.kt} | 137 ++++++++++-------- .../vbox/backend/data/http/CacheVideoApi.kt | 11 +- .../recent/CacheRecentlyWatchedRepository.kt | 9 +- .../data/saved/CacheSavedMovieRepository.kt | 9 +- .../vbox/backend/di/HttpModule.kt | 8 +- .../RecentlyWatchedRepositoryFactory.kt | 6 +- .../di/factory/SavedMovieRepositoryFactory.kt | 6 +- .../vbox/backend/domain/CacheStorage.kt | 29 ++++ 9 files changed, 130 insertions(+), 89 deletions(-) rename apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/data/{RedisCache.kt => RedisCacheStorage.kt} (62%) create mode 100644 apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/domain/CacheStorage.kt diff --git a/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/Main.kt b/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/Main.kt index ea1b5c5..e3baeb2 100644 --- a/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/Main.kt +++ b/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/Main.kt @@ -8,7 +8,7 @@ import org.koin.core.component.KoinComponent import org.koin.core.component.get import org.koin.plugin.module.dsl.startKoin 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.configureRouting import ru.shadowsparky.vbox.backend.presentation.configureSerialization @@ -24,7 +24,7 @@ fun main(args: Array) { fun Application.module() { monitor.subscribe(ApplicationStopping) { - runBlocking { koin.getOrNull()?.disconnect() } + runBlocking { koin.getOrNull()?.close() } } val koin = object : KoinComponent {} configureSerialization() diff --git a/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/data/RedisCache.kt b/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/data/RedisCacheStorage.kt similarity index 62% rename from apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/data/RedisCache.kt rename to apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/data/RedisCacheStorage.kt index 16c1184..8c1a704 100644 --- a/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/data/RedisCache.kt +++ b/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/data/RedisCacheStorage.kt @@ -9,27 +9,93 @@ import io.lettuce.core.api.coroutines.RedisCoroutinesCommands import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext +import kotlinx.serialization.KSerializer import kotlinx.serialization.json.Json -import org.koin.core.annotation.Factory +import org.koin.core.annotation.Single import org.slf4j.LoggerFactory import ru.shadowsparky.backend.data.EnvFetcher import ru.shadowsparky.domain.DispatcherProvider +import ru.shadowsparky.vbox.backend.domain.CacheStorage +import java.util.concurrent.ConcurrentHashMap import kotlin.time.Duration @OptIn(ExperimentalLettuceCoroutinesApi::class) -@Factory -class RedisCache( +@Single +class RedisCacheStorage( private val envFetcher: EnvFetcher, private val dispatcherProvider: DispatcherProvider, - val json: Json -) { - private val logger = LoggerFactory.getLogger(RedisCache::class.java) + private val json: Json +) : CacheStorage { + private val logger = LoggerFactory.getLogger("RedisCacheStorage") private var client: RedisClient? = null private var connection: StatefulRedisConnection? = null private var commands: RedisCoroutinesCommands? = null - private val connectionMutex = Mutex() + private val locks = ConcurrentHashMap() + + override suspend fun getOrCreate( + key: String, + serializer: KSerializer, + 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 write( + key: String, + value: T, + serializer: KSerializer, + 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 read(key: String, serializer: KSerializer): 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 { commands?.let { return it } @@ -56,61 +122,4 @@ class RedisCache( } } } - - suspend inline fun 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 transact( - key: String, - ttl: Duration? = null, - createNew: suspend () -> T - ): T { - read(key)?.let { return it } - val result = createNew() - write(key, result, ttl) - return result - } - - suspend inline fun 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 успешно отключен") - } - } - } } - diff --git a/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/data/http/CacheVideoApi.kt b/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/data/http/CacheVideoApi.kt index 60366d6..15ba6ab 100644 --- a/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/data/http/CacheVideoApi.kt +++ b/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/data/http/CacheVideoApi.kt @@ -1,7 +1,8 @@ package ru.shadowsparky.vbox.backend.data.http 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.model.VideoDetails import ru.shadowsparky.vbox.shared.domain.model.VideoLinksResponse @@ -11,12 +12,12 @@ import kotlin.time.Duration.Companion.days class CacheVideoApi( private val wrapper: VideoApi, private val filter: FuzzyMovieSearchFilter, - private val redis: RedisCache + private val redis: CacheStorage ) : VideoApi { private val ttl = 3.days 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 { if (query != null) { it.copy(items = filter.filter(it.items, query)) @@ -28,11 +29,11 @@ class CacheVideoApi( } 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 { - return redis.transact("$PREFIX:links:$id:$seasonId", ttl) { + return redis.getOrCreate("$PREFIX:links:$id:$seasonId", ttl) { wrapper.fetchVideoLinks(id, seasonId) } } diff --git a/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/data/recent/CacheRecentlyWatchedRepository.kt b/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/data/recent/CacheRecentlyWatchedRepository.kt index 94ad566..ead4376 100644 --- a/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/data/recent/CacheRecentlyWatchedRepository.kt +++ b/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/data/recent/CacheRecentlyWatchedRepository.kt @@ -3,14 +3,15 @@ package ru.shadowsparky.vbox.backend.data.recent import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.first 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.model.RecentlyWatchedInfo import kotlin.time.Duration.Companion.days class CacheRecentlyWatchedRepository( private val wrapper: RecentlyWatchedRepository, - private val redis: RedisCache, + private val redis: CacheStorage, userId: Long ) : RecentlyWatchedRepository { private val prefix = "recently_watched:$userId" @@ -38,7 +39,7 @@ class CacheRecentlyWatchedRepository( ): Flow> { return flow { emit( - redis.transact( + redis.getOrCreate( "$prefix:$movieId:$seasonId", 7.days ) { wrapper.queryRecentlyWatched(movieId, seasonId).first() } @@ -47,7 +48,7 @@ class CacheRecentlyWatchedRepository( } override suspend fun getAllRecentlyWatched(): List { - return redis.transact( + return redis.getOrCreate( prefix, 7.days ) { wrapper.getAllRecentlyWatched() } diff --git a/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/data/saved/CacheSavedMovieRepository.kt b/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/data/saved/CacheSavedMovieRepository.kt index d4f1119..c730075 100644 --- a/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/data/saved/CacheSavedMovieRepository.kt +++ b/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/data/saved/CacheSavedMovieRepository.kt @@ -3,14 +3,15 @@ package ru.shadowsparky.vbox.backend.data.saved import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.first 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.model.VideoDetails import kotlin.time.Duration.Companion.days class CacheSavedMovieRepository( private val wrapper: SavedMovieRepository, - private val redis: RedisCache, + private val redis: CacheStorage, userId: Long ) : SavedMovieRepository { private val ttl = 7.days @@ -27,13 +28,13 @@ class CacheSavedMovieRepository( override fun getAll(): 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 { return flow { - emit(redis.transact("$prefix:$id") { wrapper.isSaved(id).first() }) + emit(redis.getOrCreate("$prefix:$id", ttl) { wrapper.isSaved(id).first() }) } } diff --git a/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/di/HttpModule.kt b/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/di/HttpModule.kt index d4af85e..48deb65 100644 --- a/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/di/HttpModule.kt +++ b/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/di/HttpModule.kt @@ -3,9 +3,9 @@ package ru.shadowsparky.vbox.backend.di import org.koin.core.annotation.Factory import org.koin.core.annotation.Module 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.ExternalBackendApi +import ru.shadowsparky.vbox.backend.domain.CacheStorage import ru.shadowsparky.vbox.shared.domain.VideoApi @Module @@ -15,8 +15,8 @@ class HttpModule { fun provideVideoApi( impl: ExternalBackendApi, filter: FuzzyMovieSearchFilter, - redisCache: RedisCache + cacheStorage: CacheStorage ): VideoApi { - return CacheVideoApi(impl, filter, redisCache) + return CacheVideoApi(impl, filter, cacheStorage) } } diff --git a/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/di/factory/RecentlyWatchedRepositoryFactory.kt b/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/di/factory/RecentlyWatchedRepositoryFactory.kt index d285ecf..ead2198 100644 --- a/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/di/factory/RecentlyWatchedRepositoryFactory.kt +++ b/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/di/factory/RecentlyWatchedRepositoryFactory.kt @@ -2,9 +2,9 @@ package ru.shadowsparky.vbox.backend.di.factory import org.koin.core.annotation.Factory 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.CacheRecentlyWatchedRepository +import ru.shadowsparky.vbox.backend.domain.CacheStorage import ru.shadowsparky.vbox.shared.di.factory.DispatcherProvider import ru.shadowsparky.vbox.shared.domain.RecentlyWatchedRepository import ru.shadowsparky.vbox.shared.domain.RemoteEventHandler @@ -14,13 +14,13 @@ class RecentlyWatchedRepositoryFactory( private val db: AppDatabase, private val dispatcherProvider: DispatcherProvider, private val remoteEventHandler: RemoteEventHandler, - private val redisCache: RedisCache + private val cacheStorage: CacheStorage ) { fun create(userId: Long): RecentlyWatchedRepository { val impl = BackendRecentlyWatchedRepository(db, userId, dispatcherProvider, remoteEventHandler) return CacheRecentlyWatchedRepository( impl, - redisCache, + cacheStorage, userId ) } diff --git a/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/di/factory/SavedMovieRepositoryFactory.kt b/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/di/factory/SavedMovieRepositoryFactory.kt index 2fbec99..f7403ae 100644 --- a/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/di/factory/SavedMovieRepositoryFactory.kt +++ b/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/di/factory/SavedMovieRepositoryFactory.kt @@ -4,9 +4,9 @@ import org.koin.core.annotation.Factory import ru.shadowsparky.backend.data.Logger import ru.shadowsparky.vbox.backend.AppDatabase 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.CacheSavedMovieRepository +import ru.shadowsparky.vbox.backend.domain.CacheStorage import ru.shadowsparky.vbox.shared.di.factory.DispatcherProvider import ru.shadowsparky.vbox.shared.domain.SavedMovieRepository @@ -16,10 +16,10 @@ class SavedMovieRepositoryFactory( private val dispatcherProvider: DispatcherProvider, private val logger: Logger, private val eventHandler: BackendRemoteEventHandler, - private val redisCache: RedisCache + private val cacheStorage: CacheStorage ) { fun create(userId: Long): SavedMovieRepository { val impl = BackendSavedMovieRepository(db, userId, dispatcherProvider, logger, eventHandler) - return CacheSavedMovieRepository(impl, redisCache, userId) + return CacheSavedMovieRepository(impl, cacheStorage, userId) } } diff --git a/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/domain/CacheStorage.kt b/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/domain/CacheStorage.kt new file mode 100644 index 0000000..35a0125 --- /dev/null +++ b/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/domain/CacheStorage.kt @@ -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 getOrCreate( + key: String, + serializer: KSerializer, + ttl: Duration? = null, + createNew: suspend () -> T, + ): T + suspend fun delete(key: String) + suspend fun close() +} + +suspend inline fun CacheStorage.getOrCreate( + key: String, + ttl: Duration, + noinline createNew: suspend () -> T, +): T { + return getOrCreate( + key = key, + serializer = serializer(), + ttl = ttl, + createNew = createNew + ) +}