feat(server-02): promote EventStore to global-sequence store with subscribeAll

StoredEvent gains sessionSequence: Long (per-session monotonic); existing
sequence field becomes the global monotonic cursor across the entire store.
SqliteEventStore allocates both counters inside the same BEGIN IMMEDIATE
transaction and emits to a global MutableSharedFlow after commit.
InMemoryEventStore mirrors this with an AtomicLong global counter and a
suspend-on-overflow SharedFlow. EventStore interface gains subscribeAll()
and lastGlobalSequence(). Contract tests updated and extended to cover
global-sequence and cross-session subscribeAll invariants.
This commit is contained in:
2026-05-24 22:23:43 +04:00
parent 0cfb784187
commit 70420083ac
8 changed files with 184 additions and 77 deletions
@@ -1,11 +1,16 @@
package com.correx.testing.contracts.fixtures.events.store
import com.correx.core.events.events.StoredEvent
import com.correx.core.events.stores.EventStore
import com.correx.core.events.types.EventId
import com.correx.core.events.types.SessionId
import com.correx.testing.contracts.utils.ConcurrencyRunner
import com.correx.testing.fixtures.EventFixtures
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.yield
import org.junit.jupiter.api.Assertions
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.assertThrows
@@ -83,22 +88,22 @@ abstract class EventStoreContractTest {
}
@Test
fun `sequences are strictly increasing per session`(): Unit = runBlocking {
fun `sessionSequences are strictly increasing per session`(): Unit = runBlocking {
val store = store()
repeat(100) {
store.append(EventFixtures.newEvent(EventId("e$it"), SessionId("s1")))
}
val seqs = store.read(SessionId("s1")).map { it.sequence }
val sessionSeqs = store.read(SessionId("s1")).map { it.sessionSequence }
sessionSeqs.zipWithNext().forEach { (a, b) -> Assertions.assertTrue(a < b) }
seqs.zipWithNext().forEach { (a, b) ->
Assertions.assertTrue(a < b)
}
val globalSeqs = store.read(SessionId("s1")).map { it.sequence }
globalSeqs.zipWithNext().forEach { (a, b) -> Assertions.assertTrue(a < b) }
}
@Test
fun `sequences are isolated per session`(): Unit = runBlocking {
fun `sessionSequences are isolated per session and global sequence spans all`(): Unit = runBlocking {
val store = store()
repeat(50) {
@@ -106,11 +111,17 @@ abstract class EventStoreContractTest {
store.append(EventFixtures.newEvent(EventId("b$it"), SessionId("B")))
}
val aSeq = store.read(SessionId("A")).map { it.sequence }
val bSeq = store.read(SessionId("B")).map { it.sequence }
val aSessionSeq = store.read(SessionId("A")).map { it.sessionSequence }
val bSessionSeq = store.read(SessionId("B")).map { it.sessionSequence }
Assertions.assertEquals(aSeq.sorted(), aSeq)
Assertions.assertEquals(bSeq.sorted(), bSeq)
Assertions.assertEquals(aSessionSeq.sorted(), aSessionSeq)
Assertions.assertEquals(bSessionSeq.sorted(), bSessionSeq)
Assertions.assertEquals(1L, aSessionSeq.first())
Assertions.assertEquals(1L, bSessionSeq.first())
val allGlobal = (store.read(SessionId("A")) + store.read(SessionId("B")))
.map { it.sequence }.sorted()
allGlobal.zipWithNext().forEach { (a, b) -> Assertions.assertTrue(a < b) }
}
@Test
@@ -127,7 +138,7 @@ abstract class EventStoreContractTest {
Assertions.assertEquals(50, result.size)
val seqs = result.map { it.sequence }
val seqs = result.map { it.sessionSequence }
Assertions.assertEquals(seqs.sorted(), seqs)
}
@@ -149,7 +160,7 @@ abstract class EventStoreContractTest {
val all = store.read(SessionId("s1"))
val seqs = all.map { it.sequence }
val seqs = all.map { it.sessionSequence }
Assertions.assertEquals(seqs.sorted(), seqs)
}
@@ -179,7 +190,48 @@ abstract class EventStoreContractTest {
val first = store.readFrom(SessionId("s1"), 3)
val second = store.readFrom(SessionId("s1"), 7)
Assertions.assertTrue(first.all { it.sequence > 3 })
Assertions.assertTrue(second.all { it.sequence > 7 })
Assertions.assertTrue(first.all { it.sessionSequence > 3 })
Assertions.assertTrue(second.all { it.sessionSequence > 7 })
}
@Test
fun `lastGlobalSequence returns 0 when store is empty`(): Unit = runBlocking {
val store = store()
Assertions.assertEquals(0L, store.lastGlobalSequence())
}
@Test
fun `lastGlobalSequence equals highest sequence after appends`(): Unit = runBlocking {
val store = store()
store.append(EventFixtures.newEvent(EventId("x1"), SessionId("s1")))
store.append(EventFixtures.newEvent(EventId("x2"), SessionId("s2")))
store.append(EventFixtures.newEvent(EventId("x3"), SessionId("s1")))
val allSeqs = (store.read(SessionId("s1")) + store.read(SessionId("s2")))
.map { it.sequence }
Assertions.assertEquals(allSeqs.max(), store.lastGlobalSequence())
}
@Test
fun `subscribeAll emits events from all sessions`(): Unit = runBlocking {
val store = store()
val received = mutableListOf<StoredEvent>()
val job = launch {
store.subscribeAll().collect { received.add(it) }
}
yield()
store.append(EventFixtures.newEvent(EventId("p1"), SessionId("alpha")))
store.append(EventFixtures.newEvent(EventId("q1"), SessionId("beta")))
delay(100)
job.cancel()
Assertions.assertEquals(2, received.size)
val sessionIds = received.map { it.metadata.sessionId.value }.toSet()
Assertions.assertTrue("alpha" in sessionIds)
Assertions.assertTrue("beta" in sessionIds)
}
}