1+ package dev.slne.surf.redis.cache
2+
3+ import dev.slne.surf.redis.RedisApi
4+ import kotlinx.coroutines.reactive.awaitFirstOrNull
5+ import kotlinx.coroutines.reactive.awaitSingle
6+ import kotlinx.serialization.KSerializer
7+ import kotlin.time.Duration
8+
9+ /* *
10+ * A simple Redis-backed cache for values of type [V] parameterized by key type [K].
11+ *
12+ * Values are stored as JSON under keys formatted as `<namespace>:<key>`. A configurable
13+ * TTL (time-to-live) is applied to each entry. `null` values can optionally be cached
14+ * using a sentinel marker.
15+ *
16+ * This class uses the reactive Redis commands exposed by [RedisApi] and is intended
17+ * for coroutine-based usage (suspending methods).
18+ *
19+ * @param namespace String prefix that is prepended to each Redis key.
20+ * @param serializer [KSerializer] used to (de-)serialize values of type [V] to/from JSON.
21+ * @param keyToString Function that converts a key of type `K` to its String representation.
22+ * Default is `toString()`.
23+ * @param ttl Time-to-live for cache entries.
24+ * @param api Instance of [RedisApi] used to access Redis.
25+ */
26+ class SimpleRedisCache <K : Any , V : Any > internal constructor(
27+ private val namespace : String ,
28+ private val serializer : KSerializer <V >,
29+ private val keyToString : (K ) -> String = { it.toString() },
30+ private val ttl : Duration ,
31+ private val api : RedisApi
32+ ) {
33+ private companion object {
34+ private const val NULL_MARKER = " __NULL__"
35+ }
36+
37+ private val commands get() = api.connection.reactive()
38+ private fun redisKey (key : K ): String = " $namespace :${keyToString(key)} "
39+
40+ /* *
41+ * Retrieve a value from the cache.
42+ *
43+ * Returns `null` if no entry exists or if a cached `null` marker is present.
44+ *
45+ * @param key The cache key.
46+ * @return The cached value of type [V], or `null` if absent or explicitly cached as `null`.
47+ */
48+ suspend fun getCached (key : K ): V ? {
49+ val raw = commands.get(redisKey(key)).awaitFirstOrNull() ? : return null
50+
51+ if (raw == NULL_MARKER ) {
52+ return null
53+ }
54+
55+ return api.json.decodeFromString(serializer, raw)
56+ }
57+
58+ /* *
59+ * Store a value in the cache.
60+ *
61+ * The value is serialized to JSON using the provided [KSerializer] and stored with
62+ * the configured TTL.
63+ *
64+ * @param key The cache key.
65+ * @param value The value to store.
66+ */
67+ suspend fun put (key : K , value : V ) {
68+ putRaw(key, api.json.encodeToString(serializer, value))
69+ }
70+
71+ /* *
72+ * Internal helper to store already serialized data or sentinel markers.
73+ *
74+ * @param key The cache key.
75+ * @param raw The raw string to store (JSON or sentinel marker).
76+ */
77+ private suspend fun putRaw (key : K , raw : String ) {
78+ val redisKey = redisKey(key)
79+
80+ commands.psetex(
81+ redisKey,
82+ ttl.inWholeMilliseconds,
83+ raw
84+ ).awaitSingle()
85+ }
86+
87+ /* *
88+ * Return the cached value or load it if absent.
89+ *
90+ * If no entry exists, the provided suspending `loader` is invoked, the result is cached
91+ * and then returned.
92+ *
93+ * @param key The cache key.
94+ * @param loader Suspended lambda to load the value if it is not present in the cache.
95+ * @return The existing or newly loaded value.
96+ */
97+ suspend fun cachedOrLoad (key : K , loader : suspend () -> V ): V {
98+ getCached(key)?.let { return it }
99+ val loaded = loader()
100+ put(key, loaded)
101+ return loaded
102+ }
103+
104+ /* *
105+ * Return the cached value or load it if absent (nullable variant).
106+ *
107+ * If the loader returns `null`, the `cacheNull` flag controls whether a sentinel
108+ * marker is stored so subsequent calls do not trigger the loader again.
109+ *
110+ * @param key The cache key.
111+ * @param cacheNull If `true`, `null` results are cached using a sentinel marker.
112+ * @param loader Suspended lambda to load the nullable value if it is not present.
113+ * @return The existing or newly loaded value, or `null`.
114+ */
115+ suspend fun cachedOrLoadNullable (
116+ key : K ,
117+ cacheNull : Boolean = false,
118+ loader : suspend () -> V ?
119+ ): V ? {
120+ getCached(key)?.let { return it }
121+
122+ val loaded = loader()
123+ when {
124+ loaded != null -> put(key, loaded)
125+ cacheNull -> putRaw(key, NULL_MARKER )
126+ }
127+
128+ return loaded
129+ }
130+
131+ /* *
132+ * Remove an entry from the cache.
133+ *
134+ * @param key The cache key to remove.
135+ * @return The number of keys removed (typically 0 or 1).
136+ */
137+ suspend fun invalidate (key : K ): Long {
138+ return commands.del(redisKey(key)).awaitSingle()
139+ }
140+ }
0 commit comments