feat: remove snapshot on ProjectionRepository
Some checks failed
Tests / build (push) Failing after 6m58s
Tests / lint (push) Has been skipped
Tests / test (push) Has been skipped

This commit is contained in:
2025-04-17 01:24:25 +02:00
parent b6e8a2f347
commit cba9971ca1
37 changed files with 478 additions and 871 deletions

View File

@@ -7,6 +7,8 @@ import eventDemo.business.entity.Player
import eventDemo.business.event.event.GameEvent
import eventDemo.business.event.event.NewPlayerEvent
import eventDemo.libs.event.VersionBuilderLocal
import eventDemo.testApplicationWithConfig
import io.kotest.assertions.nondeterministic.until
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.collections.shouldHaveSize
import io.kotest.matchers.equals.shouldBeEqual
@@ -16,6 +18,7 @@ import io.mockk.spyk
import io.mockk.verify
import kotlin.test.assertIs
import kotlin.test.assertNotNull
import kotlin.time.Duration.Companion.seconds
class GameEventHandlerTest :
FunSpec({

View File

@@ -2,7 +2,6 @@ package eventDemo.business.event.projection
import ch.qos.logback.classic.Level
import com.rabbitmq.client.impl.ForgivingExceptionHandler
import com.zaxxer.hikari.pool.ProxyConnection
import eventDemo.Tag
import eventDemo.business.command.GameCommandHandler
import eventDemo.business.entity.GameId
@@ -17,7 +16,6 @@ import io.kotest.common.KotestInternal
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.collections.shouldHaveSize
import io.kotest.matchers.equals.shouldBeEqual
import io.kotest.matchers.shouldBe
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.joinAll
@@ -41,13 +39,10 @@ class GameStateRepositoryTest :
val eventHandler = get<GameEventHandler>()
eventHandler
.handle(aggregateId) { NewPlayerEvent(aggregateId = aggregateId, player = player1, version = it) }
.also { event ->
.also {
// Wait until the projection is created
eventually(1.seconds) {
assertNotNull(repo.getUntil(event)).also {
assertNotNull(it.players) shouldBeEqual setOf(player1)
}
assertNotNull(repo.getLast(aggregateId)).also {
assertNotNull(repo.get(aggregateId)).also {
assertNotNull(it.players) shouldBeEqual setOf(player1)
}
}
@@ -68,9 +63,9 @@ class GameStateRepositoryTest :
var state: GameState? = null
projectionBus.subscribe {
repo.getLast(aggregateId).also {
state = it
}
repo
.get(aggregateId)
.also { state = it }
}
eventHandler
@@ -86,7 +81,7 @@ class GameStateRepositoryTest :
.handle(aggregateId) { NewPlayerEvent(aggregateId = aggregateId, player = player2, version = it) }
.also {
eventually(1.seconds) {
assertNotNull(repo.getLast(aggregateId)).also {
assertNotNull(repo.get(aggregateId)).also {
assertNotNull(it.players) shouldBeEqual setOf(player1, player2)
}
}
@@ -95,44 +90,7 @@ class GameStateRepositoryTest :
}
}
test("getUntil should build the state until the event") {
withLogLevel(
GameCommandHandler::class.java.name to Level.ERROR,
ForgivingExceptionHandler::class.java.name to Level.OFF,
ProxyConnection::class.java.name to Level.OFF,
Logger.ROOT_LOGGER_NAME to Level.INFO,
) {
repeat(10) {
val aggregateId = GameId()
testKoinApplicationWithConfig {
val repo = get<GameStateRepository>()
val eventHandler = get<GameEventHandler>()
val event1 =
eventHandler
.handle(aggregateId) { NewPlayerEvent(aggregateId = aggregateId, player = player1, version = it) }
.also { event1 ->
assertNotNull(repo.getUntil(event1)).also {
assertNotNull(it.players) shouldBeEqual setOf(player1)
}
}
eventHandler
.handle(aggregateId) { NewPlayerEvent(aggregateId = aggregateId, player = player2, version = it) }
.also { event2 ->
assertNotNull(repo.getUntil(event2)).also {
assertNotNull(it.players) shouldBeEqual setOf(player1, player2)
}
assertNotNull(repo.getUntil(event1)).also {
assertNotNull(it.players) shouldBeEqual setOf(player1)
}
}
}
}
}
}
test("getUntil should be concurrently secure").config(tags = setOf(Tag.Concurrence)) {
test("get should be concurrently secure").config(tags = setOf(Tag.Concurrence)) {
withLogLevel(
Logger.ROOT_LOGGER_NAME to Level.ERROR,
ForgivingExceptionHandler::class.java.name to Level.OFF,
@@ -167,17 +125,12 @@ class GameStateRepositoryTest :
includeFirst = false
},
) {
repo.getLast(aggregateId).run {
repo.get(aggregateId).run {
lastEventVersion shouldBeEqual 200
players shouldHaveSize 200
}
repo.count(aggregateId) shouldBe 21
}
}
}
}
xtest("get should be concurrently secure") {
tags(Tag.Concurrence)
}
})

View File

@@ -1,267 +0,0 @@
package eventDemo.business.event.projection
import eventDemo.cleanProjections
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.DISABLED_CONFIG
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.assertions.nondeterministic.continually
import io.kotest.core.spec.style.FunSpec
import io.kotest.datatest.withData
import io.kotest.engine.names.WithDataTestName
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
import kotlin.time.Duration.Companion.seconds
@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 { repo ->
store().let { store -> TestData(store, repo(store, DISABLED_CONFIG)) }
}
}
context("when call applyAndPutToCache, the getUntil method must be use the built projection cache") {
withData(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("getList method must be return all inserted events") {
withData(list) { (eventStore, repo) ->
val aggregateId = IdTest()
val otherAggregateId = IdTest()
val eventOther = Event2Test(value2 = "valOther", version = 1, aggregateId = otherAggregateId)
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)
val event2 = Event2Test(value2 = "val2", version = 2, aggregateId = aggregateId)
eventStore.publish(event2)
repo.applyAndPutToCache(event2)
repo.getList().apply {
any { it.aggregateId == otherAggregateId } shouldBeEqual true
any { it.aggregateId == aggregateId } shouldBeEqual true
any { it.value == "val1val2" } shouldBeEqual true
any { it.value == "valOther" } shouldBeEqual true
any { it.lastEventVersion == 2 } shouldBeEqual true
any { it.lastEventVersion == 1 } shouldBeEqual true
}
}
}
context("ProjectionSnapshotRepository should be thread safe") {
continually(1.seconds) {
withData(list) { (eventStore, repo) ->
val aggregateId = IdTest()
val versionBuilder = VersionBuilderLocal()
val lock = ReentrantLock()
(0..9)
.map {
GlobalScope.launch {
(1..10).forEach {
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
assertNotNull(repo.count(aggregateId)) shouldBeEqual 100
}
}
}
context("removeOldSnapshot") {
withData(list) { (eventStore, repo) ->
val versionBuilder = VersionBuilderLocal()
val aggregateId = IdTest()
suspend 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,
): 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,
): ProjectionSnapshotRepository<TestEvents, ProjectionTest, IdTest> {
val jedis = JedisPooled("redis://localhost:6379")
jedis.cleanProjections()
return ProjectionSnapshotRepositoryInRedis(
eventStore = eventStore,
jedis = jedis,
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.orEmpty()) + event.value1)
}
is Event2Test -> {
projection.copy(value = (projection.value.orEmpty()) + event.value2)
}
is EventXTest -> {
projection.copy(num = projection.num + event.num)
}
}.copy(
lastEventVersion = event.version,
)
}
}