Last active
October 17, 2023 12:24
-
-
Save dave08/b51ca0f55c44bcd324766c7dd36ab6a6 to your computer and use it in GitHub Desktop.
Cacheable
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import io.lettuce.core.api.StatefulRedisConnection | |
import kotlinx.coroutines.future.await | |
import kotlinx.serialization.KSerializer | |
import kotlinx.serialization.json.Json | |
import kotlinx.serialization.serializer | |
import kotlin.time.Duration | |
enum class ExpiryType { | |
none, after_write, after_access | |
} | |
data class CacheConfig( | |
val name: String, | |
val expiryType: ExpiryType = ExpiryType.none, | |
val expiry: Duration = Duration.INFINITE, | |
/** | |
If this is a real null, the cache entry will not be saved at all. | |
This should ONLY be set if the function's return type is nullable! | |
*/ | |
val nullPlaceholder: String? = null, | |
) | |
interface Cacheable { | |
suspend fun <R> invalidate(vararg keys: Pair<String, List<Any>>, block: suspend () -> R): R | |
suspend fun <R> invoke( | |
name: String, | |
type: KSerializer<R>, | |
vararg params: Any, | |
saveResultIf: (R) -> Boolean = { true }, | |
block: suspend () -> R | |
): R | |
} | |
suspend inline operator fun <reified R> Cacheable.invoke( | |
name: String, | |
vararg params: Any, | |
noinline saveResultIf: (R) -> Boolean = { true }, | |
noinline block: suspend () -> R | |
): R = | |
invoke(name, serializer<R>(), *params, saveResultIf = saveResultIf, block = block) | |
fun interface GetNameStrategy { | |
fun getName(name: String, params: Array<out Any>): String | |
} | |
object NoopCacheable : Cacheable { | |
override suspend fun <R> invalidate(vararg keys: Pair<String, List<Any>>, block: suspend () -> R): R = | |
block() | |
override suspend fun <R> invoke( | |
name: String, | |
type: KSerializer<R>, | |
vararg params: Any, | |
saveResultIf: (R) -> Boolean, | |
block: suspend () -> R | |
): R = block() | |
} | |
class RedisCacheableImpl( | |
val conn: StatefulRedisConnection<String, String>, | |
val configs: Map<String, CacheConfig> = emptyMap(), | |
val getNameStrategy: GetNameStrategy = GetNameStrategy { name, params -> | |
if (params.isEmpty()) | |
name | |
else | |
"$name:${params.joinToString(",")}" | |
}, | |
private val jsonParser: Json = Json | |
) : Cacheable { | |
/** | |
* Don't use * in this list yet... I don't think del supports it properly. | |
*/ | |
override suspend fun <R> invalidate(vararg keys: Pair<String, List<Any>>, block: suspend () -> R): R { | |
keys.forEach { (name, params) -> | |
conn.async().del(getNameStrategy.getName(name, params.toTypedArray())).await() | |
} | |
return block() | |
} | |
override suspend fun <R> invoke( | |
name: String, | |
type: KSerializer<R>, | |
vararg params: Any, | |
saveResultIf: (R) -> Boolean, | |
block: suspend () -> R | |
): R { | |
val keyName = getNameStrategy.getName(name, params) | |
val result = conn.async().get(keyName).await() | |
val config = configs[name] | |
return if (result == null) { | |
val blockResult = block() | |
val resultToSave = when { | |
blockResult == null && config?.nullPlaceholder != null -> config.nullPlaceholder | |
blockResult == null || !saveResultIf(blockResult) -> null | |
else -> jsonParser.encodeToString(type, blockResult) | |
} | |
resultToSave?.let { | |
conn.async().set(keyName, it).await() | |
if ((config?.expiryType ?: ExpiryType.none) != ExpiryType.none) | |
conn.async().pexpire(keyName, config!!.expiry.inWholeMilliseconds).await() | |
} | |
blockResult | |
} else { | |
// Set expiry after access | |
if (config?.expiryType == ExpiryType.after_access) | |
conn.async().pexpire(keyName, config.expiry.inWholeMilliseconds).await() | |
// Return real null if cached value is equals to placeholder | |
if (config?.nullPlaceholder != null && result == config.nullPlaceholder) | |
null as R | |
else | |
jsonParser.decodeFromString(type, result) | |
} | |
} | |
} | |
suspend inline fun <reified R> Cacheable.cache( | |
name: String, | |
vararg params: Any, | |
noinline shouldSaveResult: (R) -> Boolean = { true }, | |
noinline block: suspend () -> R | |
): R = | |
invoke(name, serializer(), *params, saveResultIf = shouldSaveResult, block = block) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import com.lomdaat.coreservices.api.util.* | |
import io.kotest.core.spec.style.FreeSpec | |
import io.kotest.extensions.testcontainers.perTest | |
import io.lettuce.core.RedisClient | |
import io.lettuce.core.api.StatefulRedisConnection | |
import kotlinx.coroutines.delay | |
import kotlinx.serialization.Serializable | |
import org.testcontainers.containers.GenericContainer | |
import strikt.api.expect | |
import strikt.api.expectThat | |
import strikt.assertions.* | |
import kotlin.time.Duration.Companion.minutes | |
class CacheableTest : FreeSpec() { | |
init { | |
lateinit var client: RedisClient | |
lateinit var conn: StatefulRedisConnection<String, String> | |
val container = GenericContainer<Nothing>("redis:5.0.3-alpine").apply { | |
withExposedPorts(6379) | |
} | |
extensions(container.perTest()) | |
"Saves the result of a function with no parameters" { | |
val testClass = Foo(RedisCacheableImpl(conn)) | |
val results = mutableListOf<Bar>() | |
results += (1..5).map { testClass.bar() } | |
expect { | |
that(testClass.timesCalled).isEqualTo(1) | |
that(conn.sync().get("foo")).isEqualTo("""{"id":32,"name":"something"}""") | |
that(results).all { isEqualTo(Bar(32, "something")) } | |
} | |
} | |
"Saves the result of a function with multiple parameters" { | |
val testClass = Foo(RedisCacheableImpl(conn)) | |
testClass.baz(32, "something") | |
expectThat(conn.sync().keys("*")).containsExactly("foo:32,something") | |
} | |
"Sets expiry from last write" { | |
val config = listOf(CacheConfig("foo", ExpiryType.after_write, 30.minutes)).associateBy { it.name } | |
val testClass = Foo(RedisCacheableImpl(conn, config)) | |
testClass.bar() | |
expectThat(conn.sync().ttl("foo")).isEqualTo((30.minutes).inWholeSeconds) | |
} | |
"Saves cache with default configs when not specified" { | |
val testClass = Foo(RedisCacheableImpl(conn, emptyMap())) | |
testClass.bar() | |
expectThat(conn.sync().exists("foo")).isEqualTo(1) | |
} | |
"Sets expiry from last access" { | |
val config = listOf(CacheConfig("foo", ExpiryType.after_access, 30.minutes)).associateBy { it.name } | |
val testClass = Foo(RedisCacheableImpl(conn, config)) | |
testClass.bar() | |
delay(100) | |
testClass.bar() | |
expectThat(conn.sync().pttl("foo")).isGreaterThan((30.minutes).inWholeMilliseconds - 10) | |
} | |
"When function result is null" - { | |
"and nullPlaceholder setting is not set, the cache entry isn't saved" { | |
val config = listOf(CacheConfig("foo", nullPlaceholder = null)).associateBy { it.name } | |
val testClass = Foo(RedisCacheableImpl(conn, config)) | |
testClass.nullBar() | |
expectThat(conn.sync().keys("*")).isEmpty() | |
} | |
"and nullPlaceholder setting is set, the cache value is the placeholder" { | |
val placeholder = "--placeholder--" | |
val config = listOf(CacheConfig("foo", nullPlaceholder = placeholder)).associateBy { it.name } | |
val testClass = Foo(RedisCacheableImpl(conn, config)) | |
testClass.nullBar() | |
expectThat(conn.sync().get("foo")).isEqualTo(placeholder) | |
} | |
"and nullPlaceholder setting is set, null is returned when retrieving the value" { | |
val placeholder = "--placeholder--" | |
val config = listOf(CacheConfig("foo", nullPlaceholder = placeholder)).associateBy { it.name } | |
val testClass = Foo(RedisCacheableImpl(conn, config)) | |
testClass.nullBar() | |
val result = testClass.nullBar() | |
expectThat(result).isNull() | |
} | |
} | |
"Invalidates a cache entry" - { | |
"without parameters" { | |
val config = listOf(CacheConfig("foo")).associateBy { it.name } | |
val testClass = Foo(RedisCacheableImpl(conn, config)) | |
testClass.bar() | |
testClass.invBar() | |
expectThat(conn.sync().exists("foo")).isEqualTo(0) | |
} | |
"with same parameters" { | |
val config = listOf(CacheConfig("foo")).associateBy { it.name } | |
val testClass = Foo(RedisCacheableImpl(conn, config)) | |
testClass.baz(32, "something") | |
testClass.invBaz(32, "something") | |
expectThat(conn.sync().exists("foo:32,something")).isEqualTo(0) | |
} | |
} | |
"Save when condition is fullfilled" { | |
val config = listOf(CacheConfig("foo")).associateBy { it.name } | |
val testClass = Foo(RedisCacheableImpl(conn, config)) | |
testClass.dontSaveBar() | |
val result = testClass.dontSaveBar() | |
expect { | |
that(result).isEqualTo(Bar(32, "something")) | |
that(conn.sync().keys("*")).isEmpty() | |
} | |
testClass.dontSaveBar(true) | |
val result2 = testClass.dontSaveBar(true) | |
expect { | |
that(result2).isEqualTo(Bar(32, "something")) | |
that(conn.sync().keys("*")).containsExactly("foo") | |
} | |
} | |
"Cache results that are not serializable" - { | |
"int" { | |
val config = listOf(CacheConfig("foo")).associateBy { it.name } | |
val testClass = Foo(RedisCacheableImpl(conn, config)) | |
testClass.primitiveInt() | |
expect { | |
that(conn.sync().get("foo")).isEqualTo("32") | |
} | |
} | |
"null int" { | |
val config = listOf(CacheConfig("foo", nullPlaceholder = "null")).associateBy { it.name } | |
val testClass = Foo(RedisCacheableImpl(conn, config)) | |
testClass.primitiveNullInt() | |
expect { | |
that(conn.sync().get("foo")).isEqualTo("null") | |
} | |
} | |
"boolean" { | |
val config = listOf(CacheConfig("foo")).associateBy { it.name } | |
val testClass = Foo(RedisCacheableImpl(conn, config)) | |
testClass.primitiveBoolean() | |
expect { | |
that(conn.sync().get("foo")).isEqualTo("true") | |
} | |
} | |
"set" { | |
val config = listOf(CacheConfig("foo")).associateBy { it.name } | |
val testClass = Foo(RedisCacheableImpl(conn, config)) | |
testClass.setOfInts() | |
expect { | |
that(conn.sync().get("foo")).isEqualTo("[1,2,3]") | |
} | |
} | |
} | |
beforeTest { | |
val host = container.host | |
val port = container.getMappedPort(6379) | |
client = RedisClient.create("redis://$host:$port/0") | |
conn = client.connect() | |
} | |
afterTest { | |
conn.close() | |
client.shutdown() | |
} | |
} | |
} | |
@Serializable | |
data class Bar(val id: Int, val name: String) | |
class Foo(val cache: Cacheable) { | |
var timesCalled: Int = 0 | |
suspend fun bar() = cache("foo") { | |
timesCalled++ | |
Bar(32, "something") | |
} | |
suspend fun nullBar(): Bar? = cache("foo") { | |
null | |
} | |
suspend fun primitiveInt(): Int = cache("foo") { | |
32 | |
} | |
suspend fun primitiveNullInt(): Int? = cache("foo") { | |
null | |
} | |
suspend fun primitiveBoolean(): Boolean = cache("foo") { | |
true | |
} | |
suspend fun setOfInts(): Set<Int> = cache("foo") { | |
setOf(1,2,3) | |
} | |
suspend fun dontSaveBar(shouldSave: Boolean = false): Bar = cache("foo", saveResultIf = { shouldSave }) { | |
Bar(32, "something") | |
} | |
suspend fun baz(id: Int, name: String) = cache("foo", id, name) { | |
Bar(32, "something") | |
} | |
suspend fun invBar() = cache.invalidate( | |
"foo" to emptyList() | |
) { | |
} | |
suspend fun invBaz(id: Int, name: String) = cache.invalidate( | |
"foo" to listOf(id, name) | |
) { | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment