create ProjectionSnapshotRepositoryInRedis
This commit is contained in:
@@ -11,15 +11,35 @@ import eventDemo.business.event.projection.GameProjectionBus
|
|||||||
import eventDemo.business.event.projection.gameList.GameListRepository
|
import eventDemo.business.event.projection.gameList.GameListRepository
|
||||||
import eventDemo.business.event.projection.gameState.GameStateRepository
|
import eventDemo.business.event.projection.gameState.GameStateRepository
|
||||||
import eventDemo.libs.event.projection.SnapshotConfig
|
import eventDemo.libs.event.projection.SnapshotConfig
|
||||||
|
import kotlinx.serialization.KSerializer
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlinx.serialization.serializer
|
||||||
import org.koin.core.module.Module
|
import org.koin.core.module.Module
|
||||||
import org.koin.core.module.dsl.singleOf
|
import org.koin.core.module.dsl.singleOf
|
||||||
import org.koin.dsl.bind
|
import org.koin.dsl.bind
|
||||||
import redis.clients.jedis.JedisPooled
|
import redis.clients.jedis.JedisPooled
|
||||||
|
import redis.clients.jedis.UnifiedJedis
|
||||||
|
import redis.clients.jedis.json.JsonObjectMapper
|
||||||
|
|
||||||
fun Module.configureDIInfrastructure(redisUrl: String) {
|
fun Module.configureDIInfrastructure(redisUrl: String) {
|
||||||
single {
|
factory {
|
||||||
JedisPooled(redisUrl)
|
JedisPooled(redisUrl).apply {
|
||||||
}
|
setJsonObjectMapper(
|
||||||
|
object : JsonObjectMapper {
|
||||||
|
override fun <T> fromJson(
|
||||||
|
value: String,
|
||||||
|
valueType: Class<T>,
|
||||||
|
): T {
|
||||||
|
val s: KSerializer<T> = serializer(valueType) as KSerializer<T>
|
||||||
|
return Json.decodeFromString(s, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun toJson(value: Any): String =
|
||||||
|
Json.encodeToString(value)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} bind UnifiedJedis::class
|
||||||
|
|
||||||
singleOf(::GameEventBusInMemory) bind GameEventBus::class
|
singleOf(::GameEventBusInMemory) bind GameEventBus::class
|
||||||
singleOf(::GameEventStoreInMemory) bind GameEventStore::class
|
singleOf(::GameEventStoreInMemory) bind GameEventStore::class
|
||||||
|
|||||||
11
src/main/kotlin/eventDemo/libs/ListToRange.kt
Normal file
11
src/main/kotlin/eventDemo/libs/ListToRange.kt
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
package eventDemo.libs
|
||||||
|
|
||||||
|
fun List<Int>.toRanges(): List<IntRange> =
|
||||||
|
fold(listOf()) { acc, i ->
|
||||||
|
val last = acc.lastOrNull()
|
||||||
|
if (last != null && last.max() + 1 == i) {
|
||||||
|
(acc - setOf(last)) + setOf(IntRange(last.min(), i))
|
||||||
|
} else {
|
||||||
|
acc + setOf(IntRange(i, i))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
package eventDemo.libs.event.projection
|
||||||
|
|
||||||
|
import eventDemo.libs.event.AggregateId
|
||||||
|
import eventDemo.libs.event.Event
|
||||||
|
import eventDemo.libs.event.EventStore
|
||||||
|
import eventDemo.libs.toRanges
|
||||||
|
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||||
|
import io.github.oshai.kotlinlogging.withLoggingContext
|
||||||
|
import redis.clients.jedis.UnifiedJedis
|
||||||
|
import redis.clients.jedis.params.ScanParams
|
||||||
|
import redis.clients.jedis.params.SortingParams
|
||||||
|
import kotlin.reflect.KClass
|
||||||
|
|
||||||
|
class ProjectionSnapshotRepositoryInRedis<E : Event<ID>, P : Projection<ID>, ID : AggregateId>(
|
||||||
|
private val eventStore: EventStore<E, ID>,
|
||||||
|
private val jedis: UnifiedJedis,
|
||||||
|
private val initialStateBuilder: (aggregateId: ID) -> P,
|
||||||
|
private val snapshotCacheConfig: SnapshotConfig = SnapshotConfig(),
|
||||||
|
private val projectionClass: KClass<P>,
|
||||||
|
private val projectionToJson: (P) -> String,
|
||||||
|
private val jsonToProjection: (String) -> P,
|
||||||
|
private val applyToProjection: P.(event: E) -> P,
|
||||||
|
) : ProjectionSnapshotRepository<E, P, ID> {
|
||||||
|
/**
|
||||||
|
* Create a snapshot for the event
|
||||||
|
*
|
||||||
|
* 1. get the last snapshot with a version lower than that of the event
|
||||||
|
* 2. get the events with a greater version of the snapshot
|
||||||
|
* 3. apply the event to the snapshot
|
||||||
|
* 4. apply the new event to the projection
|
||||||
|
* 5. save it
|
||||||
|
* 6. remove old one
|
||||||
|
*/
|
||||||
|
override fun applyAndPutToCache(event: E): P =
|
||||||
|
getUntil(event)
|
||||||
|
.also {
|
||||||
|
save(it)
|
||||||
|
removeOldSnapshot(it.aggregateId, event.version)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the list of all [Projections][Projection]
|
||||||
|
*/
|
||||||
|
override fun getList(): List<P> =
|
||||||
|
jedis
|
||||||
|
.scan(
|
||||||
|
"0",
|
||||||
|
ScanParams()
|
||||||
|
.match(projectionClass.redisKeySearchListLatest)
|
||||||
|
.count(100),
|
||||||
|
).result
|
||||||
|
.map { jsonToProjection(it) }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the last version of the [Projection] from the cache.
|
||||||
|
*
|
||||||
|
* 1. get the last snapshot
|
||||||
|
* 2. get the missing event to the snapshot
|
||||||
|
* 3. apply the missing events to the snapshot
|
||||||
|
*/
|
||||||
|
override fun getLast(aggregateId: ID): P =
|
||||||
|
jedis
|
||||||
|
.get(projectionClass.redisKeyLatest(aggregateId))
|
||||||
|
.let(jsonToProjection)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the [Projection] to the specific [event][Event].
|
||||||
|
*
|
||||||
|
* It does not contain the [events][Event] it after this one.
|
||||||
|
*
|
||||||
|
* 1. get the last snapshot before the event
|
||||||
|
* 2. get the events with a greater version of the snapshot but lower of passed event
|
||||||
|
* 3. apply the events to the snapshot
|
||||||
|
*/
|
||||||
|
override fun getUntil(event: E): P {
|
||||||
|
val lastSnapshot =
|
||||||
|
jedis
|
||||||
|
.sort(
|
||||||
|
projectionClass.redisKey(event.aggregateId),
|
||||||
|
SortingParams()
|
||||||
|
.desc()
|
||||||
|
.by("score")
|
||||||
|
.limit(0, 1),
|
||||||
|
).firstOrNull()
|
||||||
|
?.let(jsonToProjection)
|
||||||
|
if (lastSnapshot?.lastEventVersion == event.version) {
|
||||||
|
return lastSnapshot
|
||||||
|
}
|
||||||
|
|
||||||
|
val missingEventOfSnapshot =
|
||||||
|
eventStore
|
||||||
|
.getStream(event.aggregateId)
|
||||||
|
.readVersionBetween((lastSnapshot?.lastEventVersion ?: 1)..event.version)
|
||||||
|
|
||||||
|
return if (lastSnapshot?.lastEventVersion == event.version) {
|
||||||
|
lastSnapshot
|
||||||
|
} else {
|
||||||
|
lastSnapshot.applyEvents(event.aggregateId, missingEventOfSnapshot)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun save(projection: P) {
|
||||||
|
jedis.zadd(projection.redisKeyVersion, projection.lastEventVersion.toDouble(), projectionToJson(projection))
|
||||||
|
jedis.expire(projection.redisKeyVersion, snapshotCacheConfig.maxSnapshotCacheTtl.inWholeSeconds)
|
||||||
|
jedis.set(projection.redisKeyLatest, projectionToJson(projection))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply events to the projection.
|
||||||
|
*/
|
||||||
|
private fun P?.applyEvents(
|
||||||
|
aggregateId: ID,
|
||||||
|
eventsToApply: Set<E>,
|
||||||
|
): P =
|
||||||
|
eventsToApply.fold(this ?: initialStateBuilder(aggregateId), applyToProjectionSecure)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wrap the [applyToProjection] lambda to avoid duplicate apply of the same event.
|
||||||
|
*/
|
||||||
|
private val applyToProjectionSecure: P.(event: E) -> P = { event ->
|
||||||
|
withLoggingContext("event" to event.toString(), "projection" to this.toString()) {
|
||||||
|
if (event.version == lastEventVersion + 1) {
|
||||||
|
applyToProjection(event)
|
||||||
|
} else if (event.version <= lastEventVersion) {
|
||||||
|
KotlinLogging.logger { }.warn { "Event is already is the Projection, skip apply." }
|
||||||
|
this
|
||||||
|
} else {
|
||||||
|
error("The version of the event must follow directly after the version of the projection.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun removeOldSnapshot(
|
||||||
|
aggregateId: AggregateId,
|
||||||
|
lastVersion: Int,
|
||||||
|
) {
|
||||||
|
removeByModulo(aggregateId, lastVersion)
|
||||||
|
removeTheHeadBySize(aggregateId)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun removeByModulo(
|
||||||
|
aggregateId: AggregateId,
|
||||||
|
lastVersion: Int,
|
||||||
|
) {
|
||||||
|
(lastVersion - snapshotCacheConfig.maxSnapshotCacheSize)
|
||||||
|
.let { if (it < 0) 0 else it }
|
||||||
|
.let { IntRange(it, lastVersion - 1) }
|
||||||
|
.filter { (it % snapshotCacheConfig.modulo) != 1 }
|
||||||
|
.toRanges()
|
||||||
|
.map {
|
||||||
|
jedis.zremrangeByScore(
|
||||||
|
projectionClass.redisKey(aggregateId),
|
||||||
|
it.min().toDouble(),
|
||||||
|
it.max().toDouble(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun removeTheHeadBySize(aggregateId: AggregateId) {
|
||||||
|
val size =
|
||||||
|
jedis.zcount(
|
||||||
|
projectionClass.redisKey(aggregateId),
|
||||||
|
Double.MIN_VALUE,
|
||||||
|
Double.MAX_VALUE,
|
||||||
|
)
|
||||||
|
|
||||||
|
LongRange((size - snapshotCacheConfig.maxSnapshotCacheSize), size)
|
||||||
|
.let {
|
||||||
|
jedis.zremrangeByRank(
|
||||||
|
projectionClass.redisKey(aggregateId),
|
||||||
|
1,
|
||||||
|
it.max(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val <P : Projection<*>> KClass<P>.redisKeySearchListLatest: String get() {
|
||||||
|
return "projection:$simpleName:*:latest"
|
||||||
|
}
|
||||||
|
|
||||||
|
val <P : Projection<*>> P.redisKeyVersion: String get() {
|
||||||
|
return "projection:${this::class.simpleName}:${aggregateId.id}:$lastEventVersion"
|
||||||
|
}
|
||||||
|
|
||||||
|
val <P : Projection<*>> P.redisKeyLatest: String get() {
|
||||||
|
return "projection:${this::class.simpleName}:${aggregateId.id}:latest"
|
||||||
|
}
|
||||||
|
|
||||||
|
fun <A : AggregateId, P : Projection<*>> KClass<P>.redisKeyLatest(aggregateId: A): String =
|
||||||
|
"projection:$simpleName:${aggregateId.id}:latest"
|
||||||
|
|
||||||
|
fun <P : Projection<*>, A : AggregateId> KClass<P>.redisKey(aggregateId: A): String =
|
||||||
|
"projection:$simpleName:${aggregateId.id}"
|
||||||
@@ -1,173 +0,0 @@
|
|||||||
package eventDemo.business.event.projection
|
|
||||||
|
|
||||||
import eventDemo.libs.event.AggregateId
|
|
||||||
import eventDemo.libs.event.Event
|
|
||||||
import eventDemo.libs.event.EventStore
|
|
||||||
import eventDemo.libs.event.EventStoreInMemory
|
|
||||||
import eventDemo.libs.event.VersionBuilderLocal
|
|
||||||
import eventDemo.libs.event.projection.Projection
|
|
||||||
import eventDemo.libs.event.projection.ProjectionSnapshotRepositoryInMemory
|
|
||||||
import eventDemo.libs.event.projection.SnapshotConfig
|
|
||||||
import io.kotest.core.spec.style.FunSpec
|
|
||||||
import io.kotest.matchers.equals.shouldBeEqual
|
|
||||||
import kotlinx.coroutines.DelicateCoroutinesApi
|
|
||||||
import kotlinx.coroutines.GlobalScope
|
|
||||||
import kotlinx.coroutines.joinAll
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import kotlinx.datetime.Clock
|
|
||||||
import kotlinx.datetime.Instant
|
|
||||||
import java.util.UUID
|
|
||||||
import java.util.concurrent.locks.ReentrantLock
|
|
||||||
import kotlin.concurrent.withLock
|
|
||||||
import kotlin.test.assertNotNull
|
|
||||||
|
|
||||||
@OptIn(DelicateCoroutinesApi::class)
|
|
||||||
class ProjectionSnapshotRepositoryInMemoryTest :
|
|
||||||
FunSpec({
|
|
||||||
|
|
||||||
test("when call applyAndPutToCache, the getUntil method must be use the built projection cache") {
|
|
||||||
val eventStore: EventStore<TestEvents, IdTest> = EventStoreInMemory()
|
|
||||||
val repo = getSnapshotRepoTest(eventStore)
|
|
||||||
val aggregateId = IdTest()
|
|
||||||
|
|
||||||
val eventOther = Event2Test(value2 = "valOther", version = 1, aggregateId = IdTest())
|
|
||||||
eventStore.publish(eventOther)
|
|
||||||
repo.applyAndPutToCache(eventOther)
|
|
||||||
assertNotNull(repo.getUntil(eventOther)).also {
|
|
||||||
assertNotNull(it.value) shouldBeEqual "valOther"
|
|
||||||
}
|
|
||||||
|
|
||||||
val event1 = Event1Test(value1 = "val1", version = 1, aggregateId = aggregateId)
|
|
||||||
eventStore.publish(event1)
|
|
||||||
repo.applyAndPutToCache(event1)
|
|
||||||
assertNotNull(repo.getLast(event1.aggregateId)).also {
|
|
||||||
assertNotNull(it.value) shouldBeEqual "val1"
|
|
||||||
}
|
|
||||||
assertNotNull(repo.getUntil(event1)).also {
|
|
||||||
assertNotNull(it.value) shouldBeEqual "val1"
|
|
||||||
}
|
|
||||||
|
|
||||||
val event2 = Event2Test(value2 = "val2", version = 2, aggregateId = aggregateId)
|
|
||||||
eventStore.publish(event2)
|
|
||||||
repo.applyAndPutToCache(event2)
|
|
||||||
assertNotNull(repo.getLast(event2.aggregateId)).also {
|
|
||||||
assertNotNull(it.value) shouldBeEqual "val1val2"
|
|
||||||
}
|
|
||||||
assertNotNull(repo.getUntil(event1)).also {
|
|
||||||
assertNotNull(it.value) shouldBeEqual "val1"
|
|
||||||
}
|
|
||||||
assertNotNull(repo.getUntil(event2)).also {
|
|
||||||
assertNotNull(it.value) shouldBeEqual "val1val2"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
test("ProjectionSnapshotRepositoryInMemory should be thread safe") {
|
|
||||||
val eventStore: EventStore<TestEvents, IdTest> = EventStoreInMemory()
|
|
||||||
val repo = getSnapshotRepoTest(eventStore)
|
|
||||||
val aggregateId = IdTest()
|
|
||||||
val versionBuilder = VersionBuilderLocal()
|
|
||||||
val lock = ReentrantLock()
|
|
||||||
(0..9)
|
|
||||||
.map {
|
|
||||||
GlobalScope.launch {
|
|
||||||
(1..10).map {
|
|
||||||
val eventX =
|
|
||||||
lock.withLock {
|
|
||||||
EventXTest(num = 1, version = versionBuilder.buildNextVersion(aggregateId), aggregateId = aggregateId)
|
|
||||||
.also { eventStore.publish(it) }
|
|
||||||
}
|
|
||||||
repo.applyAndPutToCache(eventX)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}.joinAll()
|
|
||||||
assertNotNull(repo.getLast(aggregateId)).num shouldBeEqual 100
|
|
||||||
}
|
|
||||||
|
|
||||||
test("removeOldSnapshot") {
|
|
||||||
val versionBuilder = VersionBuilderLocal()
|
|
||||||
val eventStore: EventStore<TestEvents, IdTest> = EventStoreInMemory()
|
|
||||||
val repo = getSnapshotRepoTest(eventStore, SnapshotConfig(2))
|
|
||||||
val aggregateId = IdTest()
|
|
||||||
|
|
||||||
fun buildEndSendEventX() {
|
|
||||||
EventXTest(num = 1, version = versionBuilder.buildNextVersion(aggregateId), aggregateId = aggregateId)
|
|
||||||
.also { eventStore.publish(it) }
|
|
||||||
.also { repo.applyAndPutToCache(it) }
|
|
||||||
}
|
|
||||||
|
|
||||||
buildEndSendEventX()
|
|
||||||
repo.getLast(aggregateId).num shouldBeEqual 1
|
|
||||||
buildEndSendEventX()
|
|
||||||
repo.getLast(aggregateId).num shouldBeEqual 2
|
|
||||||
buildEndSendEventX()
|
|
||||||
repo.getLast(aggregateId).num shouldBeEqual 3
|
|
||||||
buildEndSendEventX()
|
|
||||||
repo.getLast(aggregateId).num shouldBeEqual 4
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
@JvmInline
|
|
||||||
private value class IdTest(
|
|
||||||
override val id: UUID = UUID.randomUUID(),
|
|
||||||
) : AggregateId
|
|
||||||
|
|
||||||
private data class ProjectionTest(
|
|
||||||
override val aggregateId: IdTest,
|
|
||||||
override val lastEventVersion: Int = 0,
|
|
||||||
var value: String? = null,
|
|
||||||
var num: Int = 0,
|
|
||||||
) : Projection<IdTest>
|
|
||||||
|
|
||||||
private sealed interface TestEvents : Event<IdTest>
|
|
||||||
|
|
||||||
private data class Event1Test(
|
|
||||||
override val eventId: UUID = UUID.randomUUID(),
|
|
||||||
override val aggregateId: IdTest,
|
|
||||||
override val createdAt: Instant = Clock.System.now(),
|
|
||||||
override val version: Int,
|
|
||||||
val value1: String,
|
|
||||||
) : TestEvents
|
|
||||||
|
|
||||||
private data class Event2Test(
|
|
||||||
override val eventId: UUID = UUID.randomUUID(),
|
|
||||||
override val aggregateId: IdTest,
|
|
||||||
override val createdAt: Instant = Clock.System.now(),
|
|
||||||
override val version: Int,
|
|
||||||
val value2: String,
|
|
||||||
) : TestEvents
|
|
||||||
|
|
||||||
private data class EventXTest(
|
|
||||||
override val eventId: UUID = UUID.randomUUID(),
|
|
||||||
override val aggregateId: IdTest,
|
|
||||||
override val createdAt: Instant = Clock.System.now(),
|
|
||||||
override val version: Int,
|
|
||||||
val num: Int,
|
|
||||||
) : TestEvents
|
|
||||||
|
|
||||||
private fun getSnapshotRepoTest(
|
|
||||||
eventStore: EventStore<TestEvents, IdTest>,
|
|
||||||
snapshotConfig: SnapshotConfig = SnapshotConfig(2000),
|
|
||||||
): ProjectionSnapshotRepositoryInMemory<TestEvents, ProjectionTest, IdTest> =
|
|
||||||
ProjectionSnapshotRepositoryInMemory(
|
|
||||||
eventStore = eventStore,
|
|
||||||
initialStateBuilder = { aggregateId: IdTest -> ProjectionTest(aggregateId) },
|
|
||||||
snapshotCacheConfig = snapshotConfig,
|
|
||||||
) { event ->
|
|
||||||
this.let { projection ->
|
|
||||||
when (event) {
|
|
||||||
is Event1Test -> {
|
|
||||||
projection.copy(value = (projection.value ?: "") + event.value1)
|
|
||||||
}
|
|
||||||
|
|
||||||
is Event2Test -> {
|
|
||||||
projection.copy(value = (projection.value ?: "") + event.value2)
|
|
||||||
}
|
|
||||||
|
|
||||||
is EventXTest -> {
|
|
||||||
projection.copy(num = projection.num + event.num)
|
|
||||||
}
|
|
||||||
}.copy(
|
|
||||||
lastEventVersion = event.version,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,230 @@
|
|||||||
|
package eventDemo.business.event.projection
|
||||||
|
|
||||||
|
import eventDemo.configuration.serializer.UUIDSerializer
|
||||||
|
import eventDemo.libs.event.AggregateId
|
||||||
|
import eventDemo.libs.event.Event
|
||||||
|
import eventDemo.libs.event.EventStore
|
||||||
|
import eventDemo.libs.event.EventStoreInMemory
|
||||||
|
import eventDemo.libs.event.VersionBuilderLocal
|
||||||
|
import eventDemo.libs.event.projection.Projection
|
||||||
|
import eventDemo.libs.event.projection.ProjectionSnapshotRepository
|
||||||
|
import eventDemo.libs.event.projection.ProjectionSnapshotRepositoryInMemory
|
||||||
|
import eventDemo.libs.event.projection.ProjectionSnapshotRepositoryInRedis
|
||||||
|
import eventDemo.libs.event.projection.SnapshotConfig
|
||||||
|
import io.kotest.core.spec.style.FunSpec
|
||||||
|
import io.kotest.datatest.WithDataTestName
|
||||||
|
import io.kotest.datatest.withData
|
||||||
|
import io.kotest.matchers.equals.shouldBeEqual
|
||||||
|
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||||
|
import kotlinx.coroutines.GlobalScope
|
||||||
|
import kotlinx.coroutines.joinAll
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.datetime.Clock
|
||||||
|
import kotlinx.datetime.Instant
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import redis.clients.jedis.JedisPooled
|
||||||
|
import java.util.UUID
|
||||||
|
import java.util.concurrent.locks.ReentrantLock
|
||||||
|
import kotlin.concurrent.withLock
|
||||||
|
import kotlin.test.assertNotNull
|
||||||
|
|
||||||
|
@OptIn(DelicateCoroutinesApi::class)
|
||||||
|
class ProjectionSnapshotRepositoryTest :
|
||||||
|
FunSpec({
|
||||||
|
data class TestData(
|
||||||
|
val store: EventStore<TestEvents, IdTest>,
|
||||||
|
val snapshotRepo: ProjectionSnapshotRepository<TestEvents, ProjectionTest, IdTest>,
|
||||||
|
) : WithDataTestName {
|
||||||
|
override fun dataTestName(): String =
|
||||||
|
"${snapshotRepo::class.simpleName} with ${store::class.simpleName}"
|
||||||
|
}
|
||||||
|
|
||||||
|
val eventStores =
|
||||||
|
listOf(
|
||||||
|
EventStoreInMemory<TestEvents, IdTest>(),
|
||||||
|
)
|
||||||
|
val projectionRepo =
|
||||||
|
listOf(
|
||||||
|
::getSnapshotRepoInMemoryTest,
|
||||||
|
::getSnapshotRepoInRedisTest,
|
||||||
|
)
|
||||||
|
|
||||||
|
val list =
|
||||||
|
eventStores.flatMap { store ->
|
||||||
|
projectionRepo.map {
|
||||||
|
TestData(store, it(store, SnapshotConfig(2000)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val nameFn: (TestData) -> String = { (eventStore, repo) ->
|
||||||
|
"${repo::class.simpleName} with ${eventStore::class.simpleName}"
|
||||||
|
}
|
||||||
|
|
||||||
|
context("when call applyAndPutToCache, the getUntil method must be use the built projection cache") {
|
||||||
|
withData(nameFn = nameFn, list) { (eventStore, repo) ->
|
||||||
|
val aggregateId = IdTest()
|
||||||
|
|
||||||
|
val eventOther = Event2Test(value2 = "valOther", version = 1, aggregateId = IdTest())
|
||||||
|
eventStore.publish(eventOther)
|
||||||
|
repo.applyAndPutToCache(eventOther)
|
||||||
|
assertNotNull(repo.getUntil(eventOther)).also {
|
||||||
|
assertNotNull(it.value) shouldBeEqual "valOther"
|
||||||
|
}
|
||||||
|
|
||||||
|
val event1 = Event1Test(value1 = "val1", version = 1, aggregateId = aggregateId)
|
||||||
|
eventStore.publish(event1)
|
||||||
|
repo.applyAndPutToCache(event1)
|
||||||
|
assertNotNull(repo.getLast(event1.aggregateId)).also {
|
||||||
|
assertNotNull(it.value) shouldBeEqual "val1"
|
||||||
|
}
|
||||||
|
assertNotNull(repo.getUntil(event1)).also {
|
||||||
|
assertNotNull(it.value) shouldBeEqual "val1"
|
||||||
|
}
|
||||||
|
|
||||||
|
val event2 = Event2Test(value2 = "val2", version = 2, aggregateId = aggregateId)
|
||||||
|
eventStore.publish(event2)
|
||||||
|
repo.applyAndPutToCache(event2)
|
||||||
|
assertNotNull(repo.getLast(event2.aggregateId)).also {
|
||||||
|
assertNotNull(it.value) shouldBeEqual "val1val2"
|
||||||
|
}
|
||||||
|
assertNotNull(repo.getUntil(event1)).also {
|
||||||
|
assertNotNull(it.value) shouldBeEqual "val1"
|
||||||
|
}
|
||||||
|
assertNotNull(repo.getUntil(event2)).also {
|
||||||
|
assertNotNull(it.value) shouldBeEqual "val1val2"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
context("ProjectionSnapshotRepositoryInMemory should be thread safe") {
|
||||||
|
withData(list) { (eventStore, repo) ->
|
||||||
|
val aggregateId = IdTest()
|
||||||
|
val versionBuilder = VersionBuilderLocal()
|
||||||
|
val lock = ReentrantLock()
|
||||||
|
(0..9)
|
||||||
|
.map {
|
||||||
|
GlobalScope.launch {
|
||||||
|
(1..10).map {
|
||||||
|
val eventX =
|
||||||
|
lock.withLock {
|
||||||
|
EventXTest(num = 1, version = versionBuilder.buildNextVersion(aggregateId), aggregateId = aggregateId)
|
||||||
|
.also { eventStore.publish(it) }
|
||||||
|
}
|
||||||
|
repo.applyAndPutToCache(eventX)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}.joinAll()
|
||||||
|
assertNotNull(repo.getLast(aggregateId)).num shouldBeEqual 100
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
context("removeOldSnapshot") {
|
||||||
|
withData(list) { (eventStore, repo) ->
|
||||||
|
val versionBuilder = VersionBuilderLocal()
|
||||||
|
val aggregateId = IdTest()
|
||||||
|
|
||||||
|
fun buildEndSendEventX() {
|
||||||
|
EventXTest(num = 1, version = versionBuilder.buildNextVersion(aggregateId), aggregateId = aggregateId)
|
||||||
|
.also { eventStore.publish(it) }
|
||||||
|
.also { repo.applyAndPutToCache(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
buildEndSendEventX()
|
||||||
|
repo.getLast(aggregateId).num shouldBeEqual 1
|
||||||
|
buildEndSendEventX()
|
||||||
|
repo.getLast(aggregateId).num shouldBeEqual 2
|
||||||
|
buildEndSendEventX()
|
||||||
|
repo.getLast(aggregateId).num shouldBeEqual 3
|
||||||
|
buildEndSendEventX()
|
||||||
|
repo.getLast(aggregateId).num shouldBeEqual 4
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
@JvmInline
|
||||||
|
@Serializable
|
||||||
|
private value class IdTest(
|
||||||
|
@Serializable(with = UUIDSerializer::class)
|
||||||
|
override val id: UUID = UUID.randomUUID(),
|
||||||
|
) : AggregateId
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
private data class ProjectionTest(
|
||||||
|
override val aggregateId: IdTest,
|
||||||
|
override val lastEventVersion: Int = 0,
|
||||||
|
var value: String? = null,
|
||||||
|
var num: Int = 0,
|
||||||
|
) : Projection<IdTest>
|
||||||
|
|
||||||
|
private sealed interface TestEvents : Event<IdTest>
|
||||||
|
|
||||||
|
private data class Event1Test(
|
||||||
|
override val eventId: UUID = UUID.randomUUID(),
|
||||||
|
override val aggregateId: IdTest,
|
||||||
|
override val createdAt: Instant = Clock.System.now(),
|
||||||
|
override val version: Int,
|
||||||
|
val value1: String,
|
||||||
|
) : TestEvents
|
||||||
|
|
||||||
|
private data class Event2Test(
|
||||||
|
override val eventId: UUID = UUID.randomUUID(),
|
||||||
|
override val aggregateId: IdTest,
|
||||||
|
override val createdAt: Instant = Clock.System.now(),
|
||||||
|
override val version: Int,
|
||||||
|
val value2: String,
|
||||||
|
) : TestEvents
|
||||||
|
|
||||||
|
private data class EventXTest(
|
||||||
|
override val eventId: UUID = UUID.randomUUID(),
|
||||||
|
override val aggregateId: IdTest,
|
||||||
|
override val createdAt: Instant = Clock.System.now(),
|
||||||
|
override val version: Int,
|
||||||
|
val num: Int,
|
||||||
|
) : TestEvents
|
||||||
|
|
||||||
|
private fun getSnapshotRepoInMemoryTest(
|
||||||
|
eventStore: EventStore<TestEvents, IdTest>,
|
||||||
|
snapshotConfig: SnapshotConfig = SnapshotConfig(2000),
|
||||||
|
): ProjectionSnapshotRepository<TestEvents, ProjectionTest, IdTest> =
|
||||||
|
ProjectionSnapshotRepositoryInMemory(
|
||||||
|
eventStore = eventStore,
|
||||||
|
initialStateBuilder = { aggregateId: IdTest -> ProjectionTest(aggregateId) },
|
||||||
|
snapshotCacheConfig = snapshotConfig,
|
||||||
|
applyToProjection = apply,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun getSnapshotRepoInRedisTest(
|
||||||
|
eventStore: EventStore<TestEvents, IdTest>,
|
||||||
|
snapshotConfig: SnapshotConfig = SnapshotConfig(2000),
|
||||||
|
): ProjectionSnapshotRepository<TestEvents, ProjectionTest, IdTest> =
|
||||||
|
ProjectionSnapshotRepositoryInRedis(
|
||||||
|
eventStore = eventStore,
|
||||||
|
jedis = JedisPooled("redis://localhost:6379"),
|
||||||
|
initialStateBuilder = { aggregateId: IdTest -> ProjectionTest(aggregateId) },
|
||||||
|
snapshotCacheConfig = snapshotConfig,
|
||||||
|
projectionClass = ProjectionTest::class,
|
||||||
|
projectionToJson = { Json.encodeToString(it) },
|
||||||
|
jsonToProjection = { Json.decodeFromString(it) },
|
||||||
|
applyToProjection = apply,
|
||||||
|
)
|
||||||
|
|
||||||
|
private val apply: ProjectionTest.(TestEvents) -> ProjectionTest = { event ->
|
||||||
|
this.let { projection ->
|
||||||
|
when (event) {
|
||||||
|
is Event1Test -> {
|
||||||
|
projection.copy(value = (projection.value ?: "") + event.value1)
|
||||||
|
}
|
||||||
|
|
||||||
|
is Event2Test -> {
|
||||||
|
projection.copy(value = (projection.value ?: "") + event.value2)
|
||||||
|
}
|
||||||
|
|
||||||
|
is EventXTest -> {
|
||||||
|
projection.copy(num = projection.num + event.num)
|
||||||
|
}
|
||||||
|
}.copy(
|
||||||
|
lastEventVersion = event.version,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user