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:
+66
-14
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -606,7 +606,8 @@ class RouterFacadeTest {
|
||||
appendedEvents.add(event)
|
||||
val stored = StoredEvent(
|
||||
metadata = event.metadata,
|
||||
sequence = nextSequence++,
|
||||
sequence = nextSequence,
|
||||
sessionSequence = nextSequence++,
|
||||
payload = event.payload,
|
||||
)
|
||||
storedEvents[event.metadata.eventId] = stored
|
||||
@@ -635,5 +636,9 @@ class RouterFacadeTest {
|
||||
storedEvents.values.asSequence()
|
||||
|
||||
override fun allSessionIds(): Set<SessionId> = storedEvents.values.map { it.metadata.sessionId }.toSet()
|
||||
|
||||
override fun subscribeAll(): Flow<StoredEvent> = TODO("Not needed in this test context")
|
||||
|
||||
override suspend fun lastGlobalSequence(): Long = TODO("Not needed in this test context")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ object EventFixtures {
|
||||
payload: EventPayload = ToolInvokedEvent("write"),
|
||||
sessionId: SessionId = SessionId("s1"),
|
||||
sequence: Long = 1L,
|
||||
sessionSequence: Long = sequence,
|
||||
timestamp: Instant = Instant.parse("2026-01-01T00:00:00Z")
|
||||
): StoredEvent {
|
||||
return StoredEvent(
|
||||
@@ -46,8 +47,8 @@ object EventFixtures {
|
||||
correlationId = null
|
||||
),
|
||||
sequence = sequence,
|
||||
sessionSequence = sessionSequence,
|
||||
payload = payload
|
||||
)
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user