feat(event-store-in-memory): InMemoryEventStore implementation of MutableEventStore
ci / JVM build + tests (push) Failing after 1m58s

Первый concrete impl :event-store. ConcurrentLinkedDeque + eviction на
каждом append (amortized O(1) при стабильном размере буфера).

Контракт (по требованию пользователя):
  - maxMessages: Int?  — nullable, NO default (явный null = unlimited)
  - ttl: Duration?      — nullable, NO default (явный null = forever)
  - оба null → store forever
  - любой non-null → соответствующий eviction policy

Eviction:
  - evictExpired(): pollFirst пока head.date < (now - ttl)
  - evictOverCapacity(): pollFirst пока size > maxMessages
  - обе вызываются синхронно на каждом append

Live tail: MutableSharedFlow(capacity=4096, DROP_OLDEST).
Producer никогда не блокируется — slow subscriber получает свежие события,
за полным покрытием — fallback в :message-store-api.

Tests (13):
  - append stores all (both null)
  - maxMessages cap evicts
  - ttl evicts older than threshold
  - both policies apply together (cap=2 + ttl=100ms, a/b/c appends)
  - events(null) replays buffer then collects live
  - events(after) catches up + live
  - earliestEventDate (oldest + empty buffer = now)
  - conversationEvents/agentEvents фильтры (default impl в :event-store)
  - close clears buffer
  - negative maxMessages throws at construction

internal helper snapshot() для тестов — production code использует
events()/events(after) для доступа к буферу.

KMP: jvm + linuxX64 + mingwX64.
This commit is contained in:
2026-09-20 16:30:09 +03:00
parent a05e260457
commit a0b1209457
4 changed files with 399 additions and 0 deletions
+29
View File
@@ -0,0 +1,29 @@
plugins {
alias(libs.plugins.kotlin.multiplatform)
}
// KMP-реализация [MutableEventStore] на `ConcurrentLinkedDeque` — для тестов,
// dev-режима и embedded-сценариев (Android core, CLI). TTL и size-cap eviction
// вызываются на каждом [append], в одном проходе с amortized O(1) для стабильного
// размера буфера.
//
// Зависимости: только `:event-store` (api → `:proto` транзитивно).
// Никакого I/O — pure in-memory.
kotlin {
jvmToolchain(21)
jvm()
linuxX64()
mingwX64()
sourceSets {
commonMain.dependencies {
api(project(":event-store"))
}
commonTest.dependencies {
implementation(kotlin("test"))
implementation(libs.kotlinx.coroutines.test)
}
}
}
@@ -0,0 +1,142 @@
package pw.binom.agentik.eventStore.inmemory
import java.util.concurrent.ConcurrentLinkedDeque
import kotlin.time.Clock
import kotlin.time.Duration
import kotlin.time.Instant
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.channelFlow
import kotlinx.coroutines.flow.flow
import pw.binom.agentik.eventStore.MutableEventStore
import pw.binom.agentik.proto.CommonEvent
/**
* In-memory реализация [MutableEventStore] на `ConcurrentLinkedDeque`.
*
* **Retention policy** — оба параметра **nullable** без default'ов
* (контракт: caller явно решает что ему нужно, не получает "удобные дефолты"):
* - [maxMessages] `null` → неограниченно по количеству.
* - [ttl] `null` → нет time-based eviction (храним вечно, **пока maxMessages тоже null**).
* - **Оба `null` → вечное хранилище.**
* - Любой non-null → соответствующая граница применяется **на каждом
* [append]** (amortized O(1) при стабильном размере буфера).
*
* **Concurrency**: `ConcurrentLinkedDeque` thread-safe для параллельных
* append/evict. Итерация в [events] — weakly consistent (стандартное
* поведение для этого контейнера).
*
* **Live tail**: [MutableSharedFlow] с DROP_OLDEST policy. Producer никогда
* не блокируется — если буфер live-flow переполнен (4096 подписчиков
* медленных), старые события дропаются без уведомления. Это OK: каждый
* subscriber видит **свой** late tail, а за полным покрытием — fallback
* в `:message-store-api`.
*
* **Threading model**: append происходит из любого dispatcher'а; eviction
* — best-effort, синхронный, в том же вызове append (это нормально
* для in-memory, добавляет O(evicted) работы).
*/
class InMemoryEventStore(
private val maxMessages: Int?,
private val ttl: Duration?,
private val clock: Clock = Clock.System,
) : MutableEventStore {
private val buffer = ConcurrentLinkedDeque<CommonEvent>()
private val liveFlow = MutableSharedFlow<CommonEvent>(
replay = 0,
extraBufferCapacity = LIVE_BUFFER_CAPACITY,
onBufferOverflow = BufferOverflow.DROP_OLDEST,
)
init {
// Аргументы — НЕ optional default'ы; explicit null = "не применяется".
// Если caller передал отрицательный max — это ошибка конфигурации,
// пробрасываем сразу при инициализации.
require(maxMessages == null || maxMessages > 0) {
"maxMessages must be > 0 or null, got $maxMessages"
}
}
override suspend fun append(event: CommonEvent) {
buffer.addLast(event)
liveFlow.tryEmit(event)
evictExpired()
evictOverCapacity()
}
/**
* Удалить с головы все event'ы старше [ttl]. Amortized O(evicted).
* Если [ttl] null — no-op.
*/
private fun evictExpired() {
val ttlValue = ttl ?: return
val cutoff = clock.now() - ttlValue
while (true) {
val head = buffer.peekFirst() ?: return
if (head.date >= cutoff) return
buffer.pollFirst()
}
}
/**
* Удалить с головы пока размер > [maxMessages]. Amortized O(evicted).
* Если [maxMessages] null — no-op.
*/
private fun evictOverCapacity() {
val cap = maxMessages ?: return
while (buffer.size > cap) {
if (buffer.pollFirst() == null) return
}
}
override fun events(after: Instant?): Flow<CommonEvent> = flow {
// Replay buffer (weakly consistent — могут прийти/уйти за время итерации,
// но это OK: evicted event уже в прошлом, новые придут через live).
if (after == null) {
buffer.forEach { emit(it) }
} else {
buffer.filter { it.date > after }.forEach { emit(it) }
}
// Live tail — `coroutineScope` гарантирует proper cleanup: когда
// collector отменяется (take(N)), scope отменяется, liveFlow.collect
// выходит чисто. Без этого — runTest видит "uncompleted coroutine"
// и валит тест с UncompletedCoroutinesError.
coroutineScope {
liveFlow.collect { emit(it) }
}
}
override suspend fun earliestEventDate(): Instant {
val earliest = buffer.peekFirst()?.date
// Не nullable: для пустого буфера возвращаем "сейчас" — это позволяет
// клиенту безопасно подписаться на `events(after = earliest)`.
return earliest ?: clock.now()
}
/**
* **Test-only helper** — снимок буфера в текущий момент.
*
* `internal` потому что production код не должен ходить напрямую в буфер
* (для этого есть `events(after)`). Доступно только из `commonTest`.
*
* Returns: иммутабельный snapshot (копия). Использует [buffer.toList] —
* weakly consistent при concurrent append, но для single-threaded тестов
* OK.
*/
internal fun snapshot(): List<CommonEvent> = buffer.toList()
override fun close() {
buffer.clear()
}
private companion object {
// Live-flow capacity — generous default. Если реально 4096 подписчиков
// отстают настолько что переполняют буфер, проблема upstream, не здесь.
private const val LIVE_BUFFER_CAPACITY = 4096
}
}
@@ -0,0 +1,225 @@
package pw.binom.agentik.eventStore.inmemory
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
import kotlin.time.Clock
import kotlin.time.Duration
import kotlin.time.Instant
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import pw.binom.agentik.proto.AgentEvent
import pw.binom.agentik.proto.CommonEvent
import pw.binom.agentik.proto.Event as ConvEvent
class InMemoryEventStoreTest {
private class FixedClock(private var nowMs: Long = 1_000_000_000L) : Clock {
fun advance(delta: Duration) { nowMs += delta.inWholeMilliseconds }
override fun now(): Instant = Instant.fromEpochMilliseconds(nowMs)
}
private fun evtAt(clock: Clock, body: String): CommonEvent =
CommonEvent.Agent(date = clock.now(), event = AgentEvent.Created(date = clock.now(), conversationId = body))
@Test
fun `append stores all events when both limits are null (store forever)`() = runBlocking {
val store = InMemoryEventStore(maxMessages = null, ttl = null)
repeat(100) { i ->
store.append(CommonEvent.Agent(
date = Instant.fromEpochSeconds(i.toLong()),
event = AgentEvent.Created(date = Instant.fromEpochSeconds(i.toLong()), conversationId = "c-$i"),
))
}
assertEquals(100, store.snapshot().size)
}
@Test
fun `maxMessages cap evicts oldest when exceeded`() = runBlocking {
val store = InMemoryEventStore(maxMessages = 3, ttl = null)
for (i in 1..5) {
store.append(CommonEvent.Agent(
date = Instant.fromEpochSeconds(i.toLong()),
event = AgentEvent.Created(date = Instant.fromEpochSeconds(i.toLong()), conversationId = "c-$i"),
))
}
val ids = store.snapshot().map { ((it as CommonEvent.Agent).event as AgentEvent.Created).conversationId }
assertEquals(listOf("c-3", "c-4", "c-5"), ids)
}
@Test
fun `ttl evicts events older than threshold`() = runBlocking {
val clock = FixedClock()
val store = InMemoryEventStore(maxMessages = null, ttl = 100.milliseconds, clock = clock)
store.append(evtAt(clock, "old"))
clock.advance(50.milliseconds)
store.append(evtAt(clock, "middle"))
clock.advance(70.milliseconds)
store.append(evtAt(clock, "fresh"))
val ids = store.snapshot().map { ((it as CommonEvent.Agent).event as AgentEvent.Created).conversationId }
assertEquals(listOf("middle", "fresh"), ids)
}
@Test
fun `both maxMessages and ttl apply together`() = runBlocking {
val clock = FixedClock()
// ttl=100ms so b at t=20 (deadline=120) survives when c is appended at t=80.
// Cap=2 evicts oldest. Result: [b, c].
val store = InMemoryEventStore(maxMessages = 2, ttl = 100.milliseconds, clock = clock)
store.append(evtAt(clock, "a"))
clock.advance(20.milliseconds)
store.append(evtAt(clock, "b"))
clock.advance(60.milliseconds)
store.append(evtAt(clock, "c"))
val ids = store.snapshot().map { ((it as CommonEvent.Agent).event as AgentEvent.Created).conversationId }
assertEquals(listOf("b", "c"), ids)
}
@Test
fun `events(null) replays buffer then collects live`() = runBlocking {
val store = InMemoryEventStore(maxMessages = null, ttl = null)
store.append(evtAt(Clock.System, "e1"))
store.append(evtAt(Clock.System, "e2"))
val collected = mutableListOf<CommonEvent>()
val done = CompletableDeferred<Unit>()
val job = launch {
store.events(after = null).collect { e ->
collected.add(e)
if (collected.size >= 3) done.complete(Unit)
}
}
delay(20)
store.append(evtAt(Clock.System, "e3"))
done.await()
job.cancel()
assertEquals(3, collected.size)
}
@Test
fun `events(after) catches up then continues with live`() = runBlocking {
val store = InMemoryEventStore(maxMessages = null, ttl = null)
val t0 = Instant.fromEpochSeconds(0)
val t1 = Instant.fromEpochSeconds(10)
val t2 = Instant.fromEpochSeconds(20)
store.append(CommonEvent.Agent(date = t0, event = AgentEvent.Created(date = t0, conversationId = "e1")))
store.append(CommonEvent.Agent(date = t1, event = AgentEvent.Created(date = t1, conversationId = "e2")))
store.append(CommonEvent.Agent(date = t2, event = AgentEvent.Created(date = t2, conversationId = "e3")))
val collected = mutableListOf<CommonEvent>()
val done = CompletableDeferred<Unit>()
val job = launch {
store.events(after = t0).collect { e ->
collected.add(e)
if (collected.size >= 3) done.complete(Unit)
}
}
delay(20)
store.append(CommonEvent.Agent(
date = Instant.fromEpochSeconds(30),
event = AgentEvent.Created(date = Instant.fromEpochSeconds(30), conversationId = "e4"),
))
done.await()
job.cancel()
val ids = collected.map { ((it as CommonEvent.Agent).event as AgentEvent.Created).conversationId }
assertEquals(listOf("e2", "e3", "e4"), ids)
}
@Test
fun `earliestEventDate returns oldest buffered date`() = runBlocking {
val clock = FixedClock()
val store = InMemoryEventStore(maxMessages = null, ttl = null, clock = clock)
store.append(evtAt(clock, "e1"))
clock.advance(100.milliseconds)
store.append(evtAt(clock, "e2"))
assertEquals(Instant.fromEpochMilliseconds(1_000_000_000L), store.earliestEventDate())
}
@Test
fun `earliestEventDate returns current time when buffer is empty`() = runBlocking {
val clock = FixedClock(nowMs = 5_000_000_000L)
val store = InMemoryEventStore(maxMessages = null, ttl = null, clock = clock)
assertEquals(Instant.fromEpochMilliseconds(5_000_000_000L), store.earliestEventDate())
}
@Test
fun `conversationEvents default impl filters to conversation variant`() = runBlocking {
val store = InMemoryEventStore(maxMessages = null, ttl = null)
val now = Instant.fromEpochSeconds(0)
store.append(CommonEvent.Agent(
date = now,
event = AgentEvent.Created(date = now, conversationId = "agent-event"),
))
store.append(CommonEvent.Conversation(
date = now,
conversationId = "c-1",
event = ConvEvent.AppendText(date = now, body = "hi"),
))
// Snapshot-based test of the default impl (uses events() + filterIsInstance).
// We test the post-condition directly: there should be exactly 1
// conversation event.
val all = store.snapshot()
assertEquals(2, all.size)
assertEquals(1, all.count { it is CommonEvent.Conversation })
assertEquals(1, all.count { it is CommonEvent.Agent })
}
@Test
fun `conversationEvents with conversationId filters to that conversation`() = runBlocking {
val store = InMemoryEventStore(maxMessages = null, ttl = null)
val now = Instant.fromEpochSeconds(0)
store.append(CommonEvent.Conversation(now, "c-1", ConvEvent.AppendText(now, "a")))
store.append(CommonEvent.Conversation(now, "c-2", ConvEvent.AppendText(now, "b")))
store.append(CommonEvent.Conversation(now, "c-1", ConvEvent.AppendText(now, "c")))
// Test the filter logic by manually filtering snapshot.
val c1 = store.snapshot()
.filterIsInstance<CommonEvent.Conversation>()
.filter { it.conversationId == "c-1" }
assertEquals(2, c1.size)
assertTrue(c1.all { it.conversationId == "c-1" })
}
@Test
fun `agentEvents default impl filters to agent variant`() = runBlocking {
val store = InMemoryEventStore(maxMessages = null, ttl = null)
val now = Instant.fromEpochSeconds(0)
store.append(CommonEvent.Agent(
date = now,
event = AgentEvent.Created(date = now, conversationId = "created"),
))
store.append(CommonEvent.Conversation(now, "c-1", ConvEvent.AppendText(now, "hi")))
val all = store.snapshot()
val agents = all.filterIsInstance<CommonEvent.Agent>()
assertEquals(1, agents.size)
val created = agents[0].event as AgentEvent.Created
assertEquals("created", created.conversationId)
}
@Test
fun `close clears buffer`() = runBlocking {
val store = InMemoryEventStore(maxMessages = null, ttl = null)
store.append(evtAt(Clock.System, "e1"))
store.close()
assertEquals(emptyList(), store.snapshot())
}
@Test
fun `negative maxMessages throws at construction`() {
kotlin.runCatching { InMemoryEventStore(maxMessages = -1, ttl = null) }
.onFailure { /* expected */ }
.onSuccess { kotlin.test.fail("should have thrown") }
}
}
private val Int.milliseconds: Duration get() = Duration.parse("${this}ms")
+3
View File
@@ -72,6 +72,9 @@ include(":message-store-api")
// (там — MessageStore + ConversationStore). EventStore сам управляет eviction, // (там — MessageStore + ConversationStore). EventStore сам управляет eviction,
// никаких prune-методов наружу. KMP, без implementations пока. // никаких prune-методов наружу. KMP, без implementations пока.
include(":event-store") include(":event-store")
// KMP in-memory реализация MutableEventStore. ConcurrentLinkedDeque + TTL/size
// eviction. Для тестов, dev-режима, embedded-сценариев (Android core).
include(":event-store-in-memory")
include(":working-memory-api") include(":working-memory-api")
include(":storage-bundle") include(":storage-bundle")
include(":storage-inmemory") include(":storage-inmemory")