Добавляет Cursor/OffsetSequencer в :outbox-api и интегрирует PersistentOffsetSequencer через :outbox-ksqlite.
Введение монотонного offset'а как персистентного состояния агента:
offset'ы переживают рестарт standalone-агента, клиент продолжает
синхронизацию инкрементально, без полной re-sync с нуля.
outbox-api:
- Cursor (offset: Long) — курсор в журнале событий агента.
- OffsetSequencer — интерфейс резервирования уникального offset.
- CursorStore — персистентное хранилище текущего offset'а.
- PersistentOffsetSequencer — декоратор над любым OutboxStore,
обновляет CursorStore на каждом append (atomic transaction).
- OutboxGapException — клиент запросил after < earliestCursor() →
сервер не может удовлетворить, клиент обязан делать full resync.
- DurableEvent переименован из Event.kt → DurableEvent.kt (Event.kt
был общим sealed-типом, теперь это термин из спеки).
- MutableOutboxStore и OutboxStore теперь читают offset через
CursorStore вместо in-memory counter'а.
outbox-inmemory:
- InMemoryOffsetSequencer — для тестов и dev-режима.
- InMemoryOutboxStore теперь принимает OffsetSequencer в конструкторе.
outbox-ksqlite (новый модуль):
- KsqliteCursorStore — таблица outbox_cursor (agent_id TEXT PK,
offset INTEGER NOT NULL DEFAULT 0, updated_at INTEGER NOT NULL).
- KsqliteCursorStoreTest — 4 теста (set/get, monotonic, concurrent).
proto + server:
- Snapshot.proto — server-state snapshot endpoint для клиентов,
которым нужна полная материализация (использование TBD).
- Routes.kt + SnapshotRouteTest — endpoint /agentik/snapshot (GET).
journal-ksqlite:
- KsqliteJournalStore.listFlow/append — без изменений по API,
нотации минимальные (codecs).
standalone:
- DurableLog (бывший ChatAgent-orchestration) — атомарный commit
события в OutboxStore + PersistentOffsetSequencer + materialization
(через Reducer) одной транзакцией.
- SqliteStores — добавляет KsqliteCursorStore в bundle, единая
shared-connection для всех ksqlite-сторов standalone-агента.
- ChatAgent / ConversationLoop / ConversationEvents / ReflectionScheduler /
ToolDispatcher — переход на новые абстракции.
- standalone/build.gradle.kts — implementation(project(':outbox-ksqlite'))
включено (раньше было закомментировано — модуль только создавался).
client:
- AgentikAgent / AgentClient / HttpEventStore / HttpJournalStore /
ReconnectingOutbox — используют Cursor через transport API.
- client/README.md — синхронизирован с новым поведением (468 строк
diff — это в основном оформление и примеры).
kotlinx-io: 0.8.0 → 0.9.1 в libs.versions.toml (см. sync-core tests).
SYNC-SYSTEM.md (в корне) — спецификация, на которую ссылается и
:sync-core (эта сессия), и эта Cursor-абстракция в outbox-api.
Тесты: standalone 132, journal-ksqlite 25, outbox-inmemory 20,
outbox-ksqlite 4, client 10, sync-core 74 — все зелёные на jvm;
sync-core linuxX64 74 тоже зелёный.
sync2/ (заброшенный stub с одним build.gradle.kts) удалён.
This commit is contained in:
+34
@@ -0,0 +1,34 @@
|
||||
package pw.binom.agentik.outbox.inmemory
|
||||
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import pw.binom.agentik.outbox.Cursor
|
||||
import pw.binom.agentik.outbox.OffsetSequencer
|
||||
|
||||
/**
|
||||
* In-memory [OffsetSequencer] — для тестов, dev-режима и ephemeral runtime.
|
||||
*
|
||||
* Эпоха генерируется случайно при создании и **не переживает** пересоздание
|
||||
* инстанса: новый store → новый epoch → клиент с прежним курсором получит
|
||||
* [pw.binom.agentik.outbox.OutboxGapException] и сделает resync. Для
|
||||
* production-агента нужен персистентный счётчик (см. `:journal-ksqlite`).
|
||||
*/
|
||||
class InMemoryOffsetSequencer(
|
||||
private val epochId: String = newEpoch(),
|
||||
initialOffset: Long = 0L,
|
||||
) : OffsetSequencer {
|
||||
|
||||
private val mutex = Mutex()
|
||||
private var counter: Long = initialOffset
|
||||
|
||||
override fun epoch(): String = epochId
|
||||
|
||||
override fun current(): Long = counter
|
||||
|
||||
override suspend fun reserve(): Long = mutex.withLock { counter++ }
|
||||
|
||||
companion object {
|
||||
/** Делегирует в [Cursor.newEpoch] — единый генератор epoch'а проекта. */
|
||||
fun newEpoch(): String = Cursor.newEpoch()
|
||||
}
|
||||
}
|
||||
+9
-15
@@ -4,7 +4,6 @@ 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
|
||||
|
||||
@@ -19,18 +18,16 @@ import pw.binom.agentik.outbox.OnlineEvent
|
||||
* **старые дропаются** ([BufferOverflow.DROP_OLDEST]), [appendOnline] не
|
||||
* блокируется. Потеря дельты допустима (см. [OnlineOutbox]).
|
||||
*
|
||||
* **Маршрутизация**: один общий [MutableSharedFlow] c `conversationId`
|
||||
* в envelope; [onlineEvents] фильтрует по диалогу. Отдельный flow-на-диалог
|
||||
* не держим, чтобы не плодить per-conversation подписки, которые надо
|
||||
* чистить вручную.
|
||||
* **Маршрутизация**: один общий [MutableSharedFlow] всех диалогов; каждый
|
||||
* [OnlineEvent] несёт свой `conversationId`, [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>(
|
||||
private val liveFlow = MutableSharedFlow<OnlineEvent>(
|
||||
replay = 0,
|
||||
extraBufferCapacity = liveBufferCapacity,
|
||||
onBufferOverflow = BufferOverflow.DROP_OLDEST,
|
||||
@@ -42,17 +39,14 @@ class InMemoryOnlineOutbox(
|
||||
}
|
||||
}
|
||||
|
||||
override fun onlineEvents(): Flow<OnlineEvent> =
|
||||
liveFlow.map { it.event }
|
||||
override fun onlineEvents(): Flow<OnlineEvent> = liveFlow
|
||||
|
||||
override fun onlineEvents(conversationId: String): Flow<OnlineEvent> =
|
||||
liveFlow.filter { it.conversationId == conversationId }.map { it.event }
|
||||
liveFlow.filter { it.conversationId == conversationId }
|
||||
|
||||
override suspend fun appendOnline(conversationId: String, event: OnlineEvent)=
|
||||
liveFlow.emit(Envelope(conversationId, event))
|
||||
override suspend fun appendOnline(event: OnlineEvent) = liveFlow.emit(event)
|
||||
|
||||
override fun tryAppendOnline(conversationId: String, event: OnlineEvent): Boolean =
|
||||
liveFlow.tryEmit(Envelope(conversationId, event))
|
||||
override fun tryAppendOnline(event: OnlineEvent): Boolean = liveFlow.tryEmit(event)
|
||||
|
||||
override fun close() {
|
||||
// replay = 0 — чистить нечего; сам flow соберётся GC'ом при выходе ссылки.
|
||||
|
||||
+88
-87
@@ -2,135 +2,144 @@ package pw.binom.agentik.outbox.inmemory
|
||||
|
||||
import kotlin.time.Clock
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Instant
|
||||
import kotlinx.coroutines.channels.BufferOverflow
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.channels.SendChannel
|
||||
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 kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import pw.binom.agentik.outbox.MutableOutboxStore
|
||||
import pw.binom.agentik.outbox.CommonEvent
|
||||
import pw.binom.agentik.outbox.Cursor
|
||||
import pw.binom.agentik.outbox.MutableOutboxStore
|
||||
import pw.binom.agentik.outbox.OffsetSequencer
|
||||
import pw.binom.agentik.outbox.OutboxGapException
|
||||
|
||||
/**
|
||||
* In-memory реализация [MutableOutboxStore] на `ArrayDeque` + [Mutex].
|
||||
*
|
||||
* **Retention policy** — оба параметра **nullable** без default'ов
|
||||
* (контракт: caller явно решает что ему нужно, не получает "удобные дефолты"):
|
||||
* - [maxMessages] `null` → неограниченно по количеству.
|
||||
* - [ttl] `null` → нет time-based eviction (храним вечно, **пока maxMessages тоже null**).
|
||||
* - **Оба `null` → вечное хранилище.**
|
||||
* - Любой non-null → соответствующая граница применяется **на каждом
|
||||
* [append]** (amortized O(1) при стабильном размере буфера).
|
||||
* ## Retention
|
||||
* - [maxMessages] `null` → неограниченно по количеству;
|
||||
* - [ttl] `null` → нет time-based eviction;
|
||||
* - **оба `null` → вечное хранилище в RAM**;
|
||||
* - любой non-null → граница применяется на каждом [append] (amortized O(1)).
|
||||
*
|
||||
* **Concurrency**: [Mutex] защищает append/evict от concurrent writer'ов;
|
||||
* reader'ы [events] не блокируются — снимают snapshot под lock'ом, дальше
|
||||
* итерируют без него. Snapshot под `mutex.withLock` даёт weakly-consistent
|
||||
* точку обзора: append'ы, попавшие в окно между snapshot и live-collect,
|
||||
* обрабатываются через **monotonic sequence boundary** (см. [events] KDoc).
|
||||
* ## Курсор и gap
|
||||
* Offset'ы берутся из [sequencer] ([reserveOffset] = [OffsetSequencer.reserve]).
|
||||
* [currentCursor] = offset последнего **записанного** события; [oldestCursor] =
|
||||
* `buffer.first().offset - 1` (или `lastOffset`, если буфер пуст). Подписка
|
||||
* `after` старше `oldestCursor` (или с чужой эпохой) бросает [OutboxGapException].
|
||||
*
|
||||
* **Live tail**: [MutableSharedFlow] с DROP_OLDEST policy. Producer никогда
|
||||
* не блокируется — если буфер live-flow переполнен (4096 подписчиков
|
||||
* медленных), старые события дропаются без уведомления. Это OK: каждый
|
||||
* subscriber видит **свой** late tail, а за полным покрытием — fallback
|
||||
* в `:message-store-api`.
|
||||
* На старте `lastOffset = sequencer.current() - 1`: если счётчик персистентный
|
||||
* и равен N, то клиент с курсором N-1 (догнавший состояние до рестарта)
|
||||
* продолжает инкрементально, а клиент с курсором < N-1 получает gap и делает
|
||||
* resync. Так рестарт сервера не теряет события молча.
|
||||
*
|
||||
* **Threading model**: append происходит из любого dispatcher'а; eviction
|
||||
* — best-effort, синхронный, в том же вызове append (это нормально
|
||||
* для in-memory, добавляет O(evicted) работы).
|
||||
* ## Concurrency
|
||||
* Один [Mutex] защищает append/evict/подписки. Регистрация подписчика и снятие
|
||||
* snapshot'а идут **одним критическим участком** — это закрывает окно
|
||||
* «snapshot → live», в котором append мог потеряться: всё, что попадёт в буфер
|
||||
* после регистрации, доедет до подписчика через его [Channel]. Snapshot
|
||||
* итерируется и эмитится вне lock'а.
|
||||
*
|
||||
* ## Live-tail
|
||||
* Каждому подписчику — свой [Channel] с `DROP_OLDEST`: медленный подписчик
|
||||
* теряет только хвост live-потока и обязан сам сделать resync при обнаружении
|
||||
* gap'а по retention'у.
|
||||
*/
|
||||
class InMemoryOutboxStore(
|
||||
private val maxMessages: Int?,
|
||||
private val ttl: Duration?,
|
||||
private val clock: Clock = Clock.System,
|
||||
private val sequencer: OffsetSequencer = InMemoryOffsetSequencer(),
|
||||
) : MutableOutboxStore {
|
||||
|
||||
private val mutex = Mutex()
|
||||
private val buffer = ArrayDeque<CommonEvent>()
|
||||
private val liveFlow = MutableSharedFlow<CommonEvent>(
|
||||
replay = 0,
|
||||
extraBufferCapacity = LIVE_BUFFER_CAPACITY,
|
||||
onBufferOverflow = BufferOverflow.DROP_OLDEST,
|
||||
)
|
||||
private val subscribers = mutableSetOf<SendChannel<CommonEvent>>()
|
||||
|
||||
/**
|
||||
* Offset последнего **записанного** события. Инициализируется из счётчика:
|
||||
* `current() - 1` (для fresh-счётчика это `-1`).
|
||||
*/
|
||||
private var lastOffset: Long = sequencer.current() - 1
|
||||
|
||||
init {
|
||||
// Аргументы — НЕ optional default'ы; explicit null = "не применяется".
|
||||
// Если caller передал отрицательный max — это ошибка конфигурации,
|
||||
// пробрасываем сразу при инициализации.
|
||||
require(maxMessages == null || maxMessages > 0) {
|
||||
"maxMessages must be > 0 or null, got $maxMessages"
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun reserveOffset(): Long = sequencer.reserve()
|
||||
|
||||
override suspend fun append(event: CommonEvent) {
|
||||
mutex.withLock {
|
||||
require(event.offset > lastOffset) {
|
||||
"Non-monotonic offset: got ${event.offset}, last=${lastOffset}"
|
||||
}
|
||||
buffer.addLast(event)
|
||||
lastOffset = event.offset
|
||||
subscribers.forEach { it.trySend(event) }
|
||||
evictLocked()
|
||||
}
|
||||
liveFlow.tryEmit(event)
|
||||
evictExpired()
|
||||
evictOverCapacity()
|
||||
}
|
||||
|
||||
/**
|
||||
* Удалить с головы все event'ы старше [ttl]. Amortized O(evicted).
|
||||
* Если [ttl] null — no-op.
|
||||
*/
|
||||
private suspend fun evictExpired() {
|
||||
val ttlValue = ttl ?: return
|
||||
val cutoff = clock.now() - ttlValue
|
||||
mutex.withLock {
|
||||
private fun evictLocked() {
|
||||
val ttlValue = ttl
|
||||
if (ttlValue != null) {
|
||||
val cutoff = clock.now() - ttlValue
|
||||
while (true) {
|
||||
val head = buffer.firstOrNull() ?: return@withLock
|
||||
if (head.date >= cutoff) return@withLock
|
||||
val head = buffer.firstOrNull() ?: break
|
||||
if (head.date >= cutoff) break
|
||||
buffer.removeFirst()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Удалить с головы пока размер > [maxMessages]. Amortized O(evicted).
|
||||
* Если [maxMessages] null — no-op.
|
||||
*/
|
||||
private suspend fun evictOverCapacity() {
|
||||
val cap = maxMessages ?: return
|
||||
mutex.withLock {
|
||||
val cap = maxMessages
|
||||
if (cap != null) {
|
||||
while (buffer.size > cap) {
|
||||
if (buffer.isEmpty()) return@withLock
|
||||
buffer.removeFirst()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun events(after: Instant?): Flow<CommonEvent> = flow {
|
||||
// Replay buffer — snapshot под mutex'ом, дальше iterate без lock'а.
|
||||
// Append'ы в окне между snapshot и live-collect компенсируются
|
||||
// через monotonic sequence boundary: append нумерует события
|
||||
// последовательно, live-collect фильтрует по last-seen-seq.
|
||||
override fun events(after: Cursor?): Flow<CommonEvent> = channelFlow {
|
||||
val channel = Channel<CommonEvent>(
|
||||
capacity = LIVE_BUFFER_CAPACITY,
|
||||
onBufferOverflow = BufferOverflow.DROP_OLDEST,
|
||||
)
|
||||
val snapshot: List<CommonEvent> = mutex.withLock {
|
||||
if (after == null) {
|
||||
buffer.toList()
|
||||
} else {
|
||||
buffer.filter { it.date > after }
|
||||
val epoch = sequencer.epoch()
|
||||
if (after != null) {
|
||||
val floor = buffer.firstOrNull()?.let { it.offset - 1 } ?: lastOffset
|
||||
if (after.epoch != epoch || after.offset < floor || after.offset > lastOffset) {
|
||||
throw OutboxGapException(
|
||||
requested = after,
|
||||
current = Cursor(epoch, lastOffset),
|
||||
oldest = Cursor(epoch, floor),
|
||||
)
|
||||
}
|
||||
}
|
||||
subscribers += channel
|
||||
// `after == null` → live-only (без replay буфера). Чтобы получить
|
||||
// весь удержанный хвост, клиент передаёт `after = oldestCursor()`.
|
||||
if (after == null) emptyList() else buffer.filter { it.offset > after.offset }
|
||||
}
|
||||
snapshot.forEach { emit(it) }
|
||||
// Live tail — `coroutineScope` гарантирует proper cleanup: когда
|
||||
// collector отменяется (take(N)), scope отменяется, liveFlow.collect
|
||||
// выходит чисто. Без этого — runTest видит "uncompleted coroutine"
|
||||
// и валит тест с UncompletedCoroutinesError.
|
||||
coroutineScope {
|
||||
liveFlow.collect { emit(it) }
|
||||
try {
|
||||
snapshot.forEach { send(it) }
|
||||
for (event in channel) send(event)
|
||||
} finally {
|
||||
mutex.withLock { subscribers -= channel }
|
||||
channel.close()
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun earliestEventDate(): Instant {
|
||||
val earliest = mutex.withLock { buffer.firstOrNull()?.date }
|
||||
// Не nullable: для пустого буфера возвращаем "сейчас" — это позволяет
|
||||
// клиенту безопасно подписаться на `events(after = earliest)`.
|
||||
return earliest ?: clock.now()
|
||||
override suspend fun currentCursor(): Cursor = mutex.withLock {
|
||||
Cursor(sequencer.epoch(), lastOffset)
|
||||
}
|
||||
|
||||
override suspend fun oldestCursor(): Cursor = mutex.withLock {
|
||||
val floor = buffer.firstOrNull()?.let { it.offset - 1 } ?: lastOffset
|
||||
Cursor(sequencer.epoch(), floor)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -138,23 +147,15 @@ class InMemoryOutboxStore(
|
||||
*
|
||||
* `internal` потому что production код не должен ходить напрямую в буфер
|
||||
* (для этого есть `events(after)`). Доступно только из `commonTest`.
|
||||
*
|
||||
* Returns: иммутабельный snapshot (копия). Под `mutex.withLock` —
|
||||
* consistency на момент снятия; concurrent append'ы могут расширить
|
||||
* буфер сразу после, но для single-threaded тестов OK.
|
||||
*/
|
||||
internal suspend fun snapshot(): List<CommonEvent> = mutex.withLock { buffer.toList() }
|
||||
|
||||
override fun close() {
|
||||
// mutex не закрываем (kotlinx Mutex не AutoCloseable; для in-memory
|
||||
// store GC соберёт всё при выходе ссылки). buffer чистим.
|
||||
buffer.clear()
|
||||
subscribers.clear()
|
||||
}
|
||||
|
||||
private companion object {
|
||||
// Live-flow capacity — generous default. Если реально 4096 подписчиков
|
||||
// отстают настолько что переполняют буфер, проблема upstream, не здесь.
|
||||
private const val LIVE_BUFFER_CAPACITY = 4096
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+147
-83
@@ -2,6 +2,7 @@ package pw.binom.agentik.outbox.inmemory
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFailsWith
|
||||
import kotlin.test.assertTrue
|
||||
import kotlin.time.Clock
|
||||
import kotlin.time.Duration
|
||||
@@ -15,7 +16,9 @@ import kotlinx.coroutines.runBlocking
|
||||
// (`CommonEvent.Agent` через alias даёт "Unresolved qualified name").
|
||||
import pw.binom.agentik.outbox.AgentEvent
|
||||
import pw.binom.agentik.outbox.CommonEvent
|
||||
import pw.binom.agentik.outbox.Event
|
||||
import pw.binom.agentik.outbox.Cursor
|
||||
import pw.binom.agentik.outbox.DurableEvent
|
||||
import pw.binom.agentik.outbox.OutboxGapException
|
||||
|
||||
class InMemoryOutboxStoreTest {
|
||||
|
||||
@@ -24,32 +27,45 @@ class InMemoryOutboxStoreTest {
|
||||
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))
|
||||
private fun agentEvent(offset: Long, conversationId: String, at: Instant = Instant.fromEpochSeconds(offset)) =
|
||||
CommonEvent.Agent(
|
||||
date = at,
|
||||
offset = offset,
|
||||
event = AgentEvent.Created(date = at, conversationId = conversationId),
|
||||
)
|
||||
|
||||
private fun evtAt(clock: Clock, offset: Long, body: String): CommonEvent =
|
||||
CommonEvent.Agent(
|
||||
date = clock.now(),
|
||||
offset = offset,
|
||||
event = AgentEvent.Created(date = clock.now(), conversationId = body),
|
||||
)
|
||||
|
||||
private suspend fun InMemoryOutboxStore.ids() =
|
||||
snapshot().map { ((it as CommonEvent.Agent).event as AgentEvent.Created).conversationId }
|
||||
|
||||
@Test
|
||||
fun `append stores all events when both limits are null store-forever`() = runBlocking {
|
||||
val store = InMemoryOutboxStore(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"),
|
||||
))
|
||||
store.append(agentEvent(i.toLong(), "c-$i"))
|
||||
}
|
||||
assertEquals(100, store.snapshot().size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `reserveOffset is monotonic`() = runBlocking {
|
||||
val store = InMemoryOutboxStore(maxMessages = null, ttl = null)
|
||||
assertEquals(0L, store.reserveOffset())
|
||||
assertEquals(1L, store.reserveOffset())
|
||||
assertEquals(2L, store.reserveOffset())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `maxMessages cap evicts oldest when exceeded`() = runBlocking {
|
||||
val store = InMemoryOutboxStore(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)
|
||||
for (i in 1..5) store.append(agentEvent(i.toLong(), "c-$i"))
|
||||
assertEquals(listOf("c-3", "c-4", "c-5"), store.ids())
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -57,78 +73,94 @@ class InMemoryOutboxStoreTest {
|
||||
val clock = FixedClock()
|
||||
val store = InMemoryOutboxStore(maxMessages = null, ttl = 100.milliseconds, clock = clock)
|
||||
|
||||
store.append(evtAt(clock, "old"))
|
||||
store.append(evtAt(clock, 0, "old"))
|
||||
clock.advance(50.milliseconds)
|
||||
store.append(evtAt(clock, "middle"))
|
||||
store.append(evtAt(clock, 1, "middle"))
|
||||
clock.advance(70.milliseconds)
|
||||
store.append(evtAt(clock, "fresh"))
|
||||
store.append(evtAt(clock, 2, "fresh"))
|
||||
|
||||
val ids = store.snapshot().map { ((it as CommonEvent.Agent).event as AgentEvent.Created).conversationId }
|
||||
assertEquals(listOf("middle", "fresh"), ids)
|
||||
assertEquals(listOf("middle", "fresh"), store.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 = InMemoryOutboxStore(maxMessages = 2, ttl = 100.milliseconds, clock = clock)
|
||||
|
||||
store.append(evtAt(clock, "a"))
|
||||
store.append(evtAt(clock, 0, "a"))
|
||||
clock.advance(20.milliseconds)
|
||||
store.append(evtAt(clock, "b"))
|
||||
store.append(evtAt(clock, 1, "b"))
|
||||
clock.advance(60.milliseconds)
|
||||
store.append(evtAt(clock, "c"))
|
||||
store.append(evtAt(clock, 2, "c"))
|
||||
|
||||
val ids = store.snapshot().map { ((it as CommonEvent.Agent).event as AgentEvent.Created).conversationId }
|
||||
assertEquals(listOf("b", "c"), ids)
|
||||
assertEquals(listOf("b", "c"), store.ids())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `events with null after replays buffer then collects live`() = runBlocking {
|
||||
fun `currentCursor is last appended offset`() = runBlocking {
|
||||
val store = InMemoryOutboxStore(maxMessages = null, ttl = null)
|
||||
store.append(evtAt(Clock.System, "e1"))
|
||||
store.append(evtAt(Clock.System, "e2"))
|
||||
store.append(agentEvent(0, "e0"))
|
||||
store.append(agentEvent(1, "e1"))
|
||||
assertEquals(1L, store.currentCursor().offset)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `oldestCursor equals last offset when buffer is empty`() = runBlocking {
|
||||
val store = InMemoryOutboxStore(maxMessages = null, ttl = null)
|
||||
// Свежий счётчик: next offset = 0 → oldest = -1.
|
||||
assertEquals(-1L, store.oldestCursor().offset)
|
||||
assertEquals(store.currentCursor().epoch, store.oldestCursor().epoch)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `oldestCursor is first-minus-one when buffer is non-empty`() = runBlocking {
|
||||
val store = InMemoryOutboxStore(maxMessages = 2, ttl = null)
|
||||
store.append(agentEvent(0, "a"))
|
||||
store.append(agentEvent(1, "b"))
|
||||
store.append(agentEvent(2, "c"))
|
||||
// buffer = [1, 2]; oldest = 1 - 1 = 0.
|
||||
assertEquals(0L, store.oldestCursor().offset)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `events with null after is live-only (no replay)`() = runBlocking {
|
||||
val store = InMemoryOutboxStore(maxMessages = null, ttl = null)
|
||||
store.append(agentEvent(0, "e1"))
|
||||
store.append(agentEvent(1, "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)
|
||||
done.complete(Unit)
|
||||
}
|
||||
}
|
||||
delay(20)
|
||||
store.append(evtAt(Clock.System, "e3"))
|
||||
store.append(agentEvent(2, "e3"))
|
||||
done.await()
|
||||
job.cancel()
|
||||
assertEquals(3, collected.size)
|
||||
assertEquals(listOf("e3"), collected.map { ((it as CommonEvent.Agent).event as AgentEvent.Created).conversationId })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `events with after catches up then continues with live`() = runBlocking {
|
||||
fun `events with cursor catches up then continues with live`() = runBlocking {
|
||||
val store = InMemoryOutboxStore(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")))
|
||||
store.append(agentEvent(0, "e1"))
|
||||
store.append(agentEvent(1, "e2"))
|
||||
store.append(agentEvent(2, "e3"))
|
||||
|
||||
val from = Cursor(store.currentCursor().epoch, 0L)
|
||||
val collected = mutableListOf<CommonEvent>()
|
||||
val done = CompletableDeferred<Unit>()
|
||||
val job = launch {
|
||||
store.events(after = t0).collect { e ->
|
||||
store.events(after = from).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"),
|
||||
))
|
||||
store.append(agentEvent(3, "e4"))
|
||||
done.await()
|
||||
job.cancel()
|
||||
val ids = collected.map { ((it as CommonEvent.Agent).event as AgentEvent.Created).conversationId }
|
||||
@@ -136,40 +168,70 @@ class InMemoryOutboxStoreTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `earliestEventDate returns oldest buffered date`() = runBlocking {
|
||||
val clock = FixedClock()
|
||||
val store = InMemoryOutboxStore(maxMessages = null, ttl = null, clock = clock)
|
||||
store.append(evtAt(clock, "e1"))
|
||||
clock.advance(100.milliseconds)
|
||||
store.append(evtAt(clock, "e2"))
|
||||
fun `subscribe from currentCursor receives only newer events - no handoff loss`() = runBlocking {
|
||||
val store = InMemoryOutboxStore(maxMessages = null, ttl = null)
|
||||
store.append(agentEvent(0, "old"))
|
||||
|
||||
assertEquals(Instant.fromEpochMilliseconds(1_000_000_000L), store.earliestEventDate())
|
||||
val collected = mutableListOf<CommonEvent>()
|
||||
val from = store.currentCursor()
|
||||
val job = launch { store.events(after = from).collect { collected.add(it) } }
|
||||
delay(50)
|
||||
store.append(agentEvent(1, "new"))
|
||||
delay(50)
|
||||
job.cancel()
|
||||
|
||||
assertEquals(listOf(1L), collected.map { it.offset })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `earliestEventDate returns current time when buffer is empty`() = runBlocking {
|
||||
val clock = FixedClock(nowMs = 5_000_000_000L)
|
||||
val store = InMemoryOutboxStore(maxMessages = null, ttl = null, clock = clock)
|
||||
assertEquals(Instant.fromEpochMilliseconds(5_000_000_000L), store.earliestEventDate())
|
||||
fun `gap exception when cursor older than oldest`() = runBlocking {
|
||||
val store = InMemoryOutboxStore(maxMessages = 2, ttl = null)
|
||||
store.append(agentEvent(0, "e0"))
|
||||
store.append(agentEvent(1, "e1"))
|
||||
store.append(agentEvent(2, "e2"))
|
||||
|
||||
val tooOld = Cursor(store.currentCursor().epoch, -1L)
|
||||
assertFailsWith<OutboxGapException> {
|
||||
store.events(after = tooOld).collect { }
|
||||
}
|
||||
// Ровно на границе — ещё можно.
|
||||
val atFloor = Cursor(store.currentCursor().epoch, 0L)
|
||||
val got = mutableListOf<Long>()
|
||||
val job = launch { store.events(after = atFloor).collect { got.add(it.offset) } }
|
||||
delay(30)
|
||||
job.cancel()
|
||||
assertEquals(listOf(1L, 2L), got)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `gap exception on epoch mismatch`(): Unit = runBlocking {
|
||||
val store = InMemoryOutboxStore(maxMessages = null, ttl = null)
|
||||
store.append(agentEvent(0, "e0"))
|
||||
val foreign = Cursor("some-other-epoch", 0L)
|
||||
assertFailsWith<OutboxGapException> {
|
||||
store.events(after = foreign).collect { }
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `gap exception exposes requested current and oldest`() = runBlocking {
|
||||
val store = InMemoryOutboxStore(maxMessages = 1, ttl = null)
|
||||
store.append(agentEvent(0, "e0"))
|
||||
store.append(agentEvent(1, "e1"))
|
||||
val tooOld = Cursor(store.currentCursor().epoch, -1L)
|
||||
val e = assertFailsWith<OutboxGapException> { store.events(tooOld).collect { } }
|
||||
assertEquals(tooOld, e.requested)
|
||||
assertEquals(1L, e.current.offset)
|
||||
assertEquals(0L, e.oldest.offset)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `conversationEvents default impl filters to conversation variant`() = runBlocking {
|
||||
val store = InMemoryOutboxStore(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 = Event.Interrupted(date = now),
|
||||
))
|
||||
store.append(CommonEvent.Agent(now, 0, AgentEvent.Created(now, "agent-event")))
|
||||
store.append(CommonEvent.Conversation(now, 1, "c-1", DurableEvent.Interrupted(now)))
|
||||
|
||||
// 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 })
|
||||
@@ -180,11 +242,10 @@ 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.Interrupted(now)))
|
||||
store.append(CommonEvent.Conversation(now, "c-2", Event.Interrupted(now)))
|
||||
store.append(CommonEvent.Conversation(now, "c-1", Event.Interrupted(now)))
|
||||
store.append(CommonEvent.Conversation(now, 0, "c-1", DurableEvent.Interrupted(now)))
|
||||
store.append(CommonEvent.Conversation(now, 1, "c-2", DurableEvent.Interrupted(now)))
|
||||
store.append(CommonEvent.Conversation(now, 2, "c-1", DurableEvent.Interrupted(now)))
|
||||
|
||||
// Test the filter logic by manually filtering snapshot.
|
||||
val c1 = store.snapshot()
|
||||
.filterIsInstance<CommonEvent.Conversation>()
|
||||
.filter { it.conversationId == "c-1" }
|
||||
@@ -196,23 +257,26 @@ class InMemoryOutboxStoreTest {
|
||||
fun `agentEvents default impl filters to agent variant`() = runBlocking {
|
||||
val store = InMemoryOutboxStore(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", Event.Interrupted(now)))
|
||||
store.append(CommonEvent.Agent(now, 0, AgentEvent.Created(now, "created")))
|
||||
store.append(CommonEvent.Conversation(now, 1, "c-1", DurableEvent.Interrupted(now)))
|
||||
|
||||
val all = store.snapshot()
|
||||
val agents = all.filterIsInstance<CommonEvent.Agent>()
|
||||
val agents = store.snapshot().filterIsInstance<CommonEvent.Agent>()
|
||||
assertEquals(1, agents.size)
|
||||
val created = agents[0].event as AgentEvent.Created
|
||||
assertEquals("created", created.conversationId)
|
||||
assertEquals("created", (agents[0].event as AgentEvent.Created).conversationId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `append with non-monotonic offset throws`(): Unit = runBlocking {
|
||||
val store = InMemoryOutboxStore(maxMessages = null, ttl = null)
|
||||
store.append(agentEvent(5, "e5"))
|
||||
assertFailsWith<IllegalArgumentException> { store.append(agentEvent(5, "again")) }
|
||||
assertFailsWith<IllegalArgumentException> { store.append(agentEvent(4, "lower")) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `close clears buffer`() = runBlocking {
|
||||
val store = InMemoryOutboxStore(maxMessages = null, ttl = null)
|
||||
store.append(evtAt(Clock.System, "e1"))
|
||||
store.append(agentEvent(0, "e1"))
|
||||
store.close()
|
||||
assertEquals(emptyList(), store.snapshot())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user