Добавляет 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:
2026-10-02 01:16:15 +03:00
parent 92b76c4e3c
commit 5bdc517988
63 changed files with 2953 additions and 1017 deletions
@@ -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()
}
}
@@ -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'ом при выходе ссылки.
@@ -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
}
}
@@ -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())
}