Изменения вводят live-канал OnlineOutbox и фоновую синхронизацию диалогов.

This commit is contained in:
2026-09-30 11:43:49 +03:00
parent 509763cf36
commit bd7e079780
25 changed files with 491 additions and 102 deletions
@@ -0,0 +1,65 @@
package pw.binom.agentik.outbox.inmemory
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.map
import pw.binom.agentik.outbox.MutableOnlineOutbox
import pw.binom.agentik.outbox.OnlineEvent
/**
* In-memory реализация [MutableOnlineOutbox] — единственный live-канал
* без хранения.
*
* **Никакого буфера событий**: [OnlineEvent] нигде не накапливаются
* (в этом весь смысл — дельты токенов не должны течь в durable-стор).
* Единственное, что живёт в памяти, — [MutableSharedFlow] с BOUNDED
* internal buffer'ом для развязки продюсера/подписчиков; при переполнении
* **старые дропаются** ([BufferOverflow.DROP_OLDEST]), [appendOnline] не
* блокируется. Потеря дельты допустима (см. [OnlineOutbox]).
*
* **Маршрутизация**: один общий [MutableSharedFlow] c `conversationId`
* в envelope; [onlineEvents] фильтрует по диалогу. Отдельный flow-на-диалог
* не держим, чтобы не плодить per-conversation подписки, которые надо
* чистить вручную.
*/
class InMemoryOnlineOutbox(
liveBufferCapacity: Int = DEFAULT_LIVE_BUFFER_CAPACITY,
) : MutableOnlineOutbox {
private data class Envelope(val conversationId: String, val event: OnlineEvent)
private val liveFlow = MutableSharedFlow<Envelope>(
replay = 0,
extraBufferCapacity = liveBufferCapacity,
onBufferOverflow = BufferOverflow.DROP_OLDEST,
)
init {
require(liveBufferCapacity > 0) {
"liveBufferCapacity must be > 0, got $liveBufferCapacity"
}
}
override fun onlineEvents(): Flow<OnlineEvent> =
liveFlow.map { it.event }
override fun onlineEvents(conversationId: String): Flow<OnlineEvent> =
liveFlow.filter { it.conversationId == conversationId }.map { it.event }
override suspend fun appendOnline(conversationId: String, event: OnlineEvent)=
liveFlow.emit(Envelope(conversationId, event))
override fun tryAppendOnline(conversationId: String, event: OnlineEvent): Boolean =
liveFlow.tryEmit(Envelope(conversationId, event))
override fun close() {
// replay = 0 — чистить нечего; сам flow соберётся GC'ом при выходе ссылки.
// Идемпотентно: повторный close() безопасен.
}
private companion object {
private const val DEFAULT_LIVE_BUFFER_CAPACITY = 1
}
}
@@ -164,7 +164,7 @@ class InMemoryOutboxStoreTest {
store.append(CommonEvent.Conversation(
date = now,
conversationId = "c-1",
event = Event.AppendText(date = now, body = "hi"),
event = Event.Working(date = now),
))
// Snapshot-based test of the default impl (uses events() + filterIsInstance).
@@ -180,9 +180,9 @@ class InMemoryOutboxStoreTest {
fun `conversationEvents with conversationId filters to that conversation`() = runBlocking {
val store = InMemoryOutboxStore(maxMessages = null, ttl = null)
val now = Instant.fromEpochSeconds(0)
store.append(CommonEvent.Conversation(now, "c-1", Event.AppendText(now, "a")))
store.append(CommonEvent.Conversation(now, "c-2", Event.AppendText(now, "b")))
store.append(CommonEvent.Conversation(now, "c-1", Event.AppendText(now, "c")))
store.append(CommonEvent.Conversation(now, "c-1", Event.Working(now)))
store.append(CommonEvent.Conversation(now, "c-2", Event.Working(now)))
store.append(CommonEvent.Conversation(now, "c-1", Event.Working(now)))
// Test the filter logic by manually filtering snapshot.
val c1 = store.snapshot()
@@ -200,7 +200,7 @@ class InMemoryOutboxStoreTest {
date = now,
event = AgentEvent.Created(date = now, conversationId = "created"),
))
store.append(CommonEvent.Conversation(now, "c-1", Event.AppendText(now, "hi")))
store.append(CommonEvent.Conversation(now, "c-1", Event.Working(now)))
val all = store.snapshot()
val agents = all.filterIsInstance<CommonEvent.Agent>()