refactor: outbox становится live-only стримом, cursor вынесен в :cursor-api
Семантика outbox'а — теперь чистый live-канал:
* OutboxStore.conversationEvents(id) — больше не принимает after-cursor.
Catchup (replay) делает клиент: journal.listFlow(afterSeq) + подписка
на live. Outbox ответственен только за уведомления «что-то произошло».
* OutboxStore.oldestCursor()/currentCursor()/OutboxGapException — удалены.
Эпоха и offset живут ТОЛЬКО в CursorHolder/CursorStore, переживают
рестарт и инкремент для каждого commit.
* DurableEvent: commit принимает блок { cursor -> MessageRecord }
(cursor выдаёт CursorStore; клиент не вычисляет offset сам).
* MessageRecord больше не несёт cursor — это не его ответственность.
Новые модули:
* :cursor-api — Cursor(epoch, offset) + CursorHolder / MutableCursorHolder
* :cursor-ksqlite — KsqliteCursorHolder (персистентный)
* :cursor-inmemory — для тестов
* :client-sync — LocalSyncAgent (мини-агент поверх :client для десктопа)
Удалены:
* :sync-core — старая референсная реализация, заменена
cursor-разделением и :client.
* outbox-ksqlite — CursorStore/Schema уехали в :cursor-ksqlite.
* OffsetSequencer / PersistentOffsetSequencer / InMemoryOffsetSequencer.
standalone:
* ChatAgent/ConversationLoop/DurableLog/ToolDispatcher/ReflectionScheduler/
ConversationEvents — подписка через push-паттерн (collect событий).
* A2aBridge — currentCursor() и conversationEvents(after=) убраны.
* SqliteStores — cursor_offset удалён из schema v4; seedNextFromJournal
читает MAX(created_at).
* Main.kt — outboxSequencer → outboxCursorHolder; user→agent (:server)
transport удалён; debug-routes удалены; A2A остался.
* Тесты ChatAgentTest/PersistenceTest переписаны на push-паттерн
(subscribe-before-act, snapshot∪live = итоговое состояние). 25/25 + 19/19 ✅
server / client:
* Routes эпоху читают из CursorHolder; снимки несут Cursor? для catchup.
* AgentikAgent и HttpEventStore — те же подписки, без after-параметра.
* ReconnectingOutbox / ReconnectingOutboxTest — без изменений API.
* JournalStore API расширен count(after=Instant?) для unread-badge.
This commit is contained in:
@@ -24,6 +24,7 @@ kotlin {
|
||||
// здесь). Message-события несут общие типы содержимого из
|
||||
// низкоуровневого :content-api (Content/MessageContext/TurnTokens).
|
||||
api(project(":content-api"))
|
||||
api(project(":cursor-api"))
|
||||
api(libs.kotlinx.coroutines.core)
|
||||
api(libs.kotlinx.serialization.core)
|
||||
api(libs.kotlinx.serialization.json)
|
||||
|
||||
@@ -12,7 +12,9 @@ import kotlinx.serialization.Serializable
|
||||
* ([AgentEvent] via `/events` и [DurableEvent] via `/conversations/{id}/events`);
|
||||
* [CommonEvent] — for those who need everything in one place.
|
||||
*
|
||||
* Server endpoint: `GET /events/all` (SSE), or replay via `OutboxStore.events(after)`.
|
||||
* **Live-уведомление, не носитель позиции.** [date] — момент эмиссии (UTC),
|
||||
* только для отображения/сортировки. Монотонный курсор для удалённого resume
|
||||
* живёт в sync-слое; outbox его не хранит и не выдаёт.
|
||||
*
|
||||
* **История**: до 2026-09-21 жил в `:proto` как `pw.binom.agentik.proto.CommonEvent`;
|
||||
* при миграции в `:outbox-api` был оставлен typealias в `:proto` для backward-compat,
|
||||
@@ -23,23 +25,13 @@ import kotlinx.serialization.Serializable
|
||||
*/
|
||||
@Serializable
|
||||
sealed interface CommonEvent {
|
||||
/** Момент эмиссии в UTC. Только для отображения/сортировки — **не** курсор. */
|
||||
/** Момент эмиссии в UTC. Только для отображения/сортировки. */
|
||||
val date: Instant
|
||||
|
||||
/**
|
||||
* Монотонный per-agent offset события — **курсор** (см. [Cursor]).
|
||||
*
|
||||
* Присваивается writer'ом через [OffsetSequencer.reserve] в тот же момент,
|
||||
* что и `seq` соответствующей строки состояния (сначала строка, потом
|
||||
* событие). Клиенты оперируют [Cursor], а не [date].
|
||||
*/
|
||||
val offset: Long
|
||||
|
||||
@Serializable
|
||||
@SerialName("agent")
|
||||
data class Agent(
|
||||
override val date: Instant,
|
||||
override val offset: Long,
|
||||
val event: AgentEvent,
|
||||
) : CommonEvent
|
||||
|
||||
@@ -47,7 +39,6 @@ sealed interface CommonEvent {
|
||||
@SerialName("conversation")
|
||||
data class Conversation(
|
||||
override val date: Instant,
|
||||
override val offset: Long,
|
||||
val conversationId: String,
|
||||
val event: DurableEvent,
|
||||
) : CommonEvent
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
package pw.binom.agentik.outbox
|
||||
|
||||
import kotlin.random.Random
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* Курсор события — **позиция в общем монотонном потоке событий агента**.
|
||||
*
|
||||
* Состоит из двух частей:
|
||||
* - [offset] — per-agent монотонный номер события (0-based). Именно он, а не
|
||||
* wall-clock [CommonEvent.date], является курсором: несколько событий могут
|
||||
* иметь одинаковый [date] (например `UserMessage` и `ToolCall` в одну
|
||||
* миллисекунду), но offset'ы всегда строго возрастают и уникальны. Фильтрация
|
||||
* `offset > after.offset` не теряет события на «ничьих» по времени.
|
||||
* - [epoch] — идентификатор «мира» счётчика. Меняется при сбросе/восстановлении
|
||||
* БД, из-за которого offset'ы теряют монотонность. Клиент хранит epoch в своём
|
||||
* курсоре; несовпадение epoch → сервер сигналит gap ([OutboxGapException]) →
|
||||
* клиент делает полный resync. Обычный **рестарт** сервера epoch НЕ меняет
|
||||
* (счётчик персистентный), поэтому клиент продолжает инкрементально.
|
||||
*
|
||||
* **Семантика подписки**: [Cursor.offset] — **эксклюзивная** граница.
|
||||
* `events(after = cursor)` отдаёт события со строго большим offset. Практически
|
||||
* клиент кладёт сюда offset последнего применённого события, либо [OutboxStore.currentCursor]
|
||||
* из снапшота.
|
||||
*
|
||||
* **Почему не `Instant` и не «id ASC»**: `Instant` лоссов при совпадении millis,
|
||||
* а `id` — случайный UUID, который не задаёт порядок записи.
|
||||
*
|
||||
* **Переполнение счётчика**: `Long` на агента неисчерпаем (≈4.6·10¹⁷ ходов при
|
||||
* 20 событиях/ход — это ~1.5·10⁷ лет при 1000 ходов/с). Заворачивать его нельзя
|
||||
* (сломает монотонность), поэтому при любом сбое, инвалидирующем счётчик
|
||||
* (сброс/восстановление БД), **ротируется [epoch]** и все клиенты делают
|
||||
* полный resync — это и есть «обработка переполнения», а не wrap.
|
||||
*/
|
||||
@Serializable
|
||||
data class Cursor(
|
||||
val epoch: String,
|
||||
val offset: Long,
|
||||
) {
|
||||
override fun toString(): String = "$epoch:$offset"
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Новый случайный [epoch] (opaque-строка). Новый epoch = «новый мир»
|
||||
* счётчика: используется при первичной инициализации персистентного
|
||||
* счётчика и при инвалидации offset-пространства — клиенты с прежним
|
||||
* курсором получат [OutboxGapException] и сделают полный resync.
|
||||
*/
|
||||
fun newEpoch(): String = Random.nextLong().toString(16).padStart(16, '0')
|
||||
}
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
package pw.binom.agentik.outbox
|
||||
|
||||
/**
|
||||
* Персистентное хранилище позиции счётчика — [Cursor] (`epoch` + `offset`).
|
||||
*
|
||||
* Единственный мост между [OffsetSequencer] (чистая логика монотонного
|
||||
* счётчика) и durable-носителем (`outbox-ksqlite`). Секвенсор читает позицию
|
||||
* один раз при создании и держит `epoch`/`offset` в памяти (см.
|
||||
* [OffsetSequencer] KDoc), поэтому [load] синхронный; [save] — durable
|
||||
* запись, вызывается на каждом [OffsetSequencer.reserve].
|
||||
*
|
||||
* Вызовы сериализованы самим [PersistentOffsetSequencer] (его `Mutex`),
|
||||
* так что реализация может не иметь собственной синхронизации.
|
||||
*/
|
||||
interface CursorStore {
|
||||
/**
|
||||
* Текущая позиция счётчика, или `null` если он ещё не инициализирован
|
||||
* (пустая БД / первый запуск). Для пустого хранилища [PersistentOffsetSequencer]
|
||||
* сгенерирует новый `epoch` и стартовый offset.
|
||||
*/
|
||||
fun load(): Cursor?
|
||||
|
||||
/** Записать позицию durable. */
|
||||
fun save(cursor: Cursor)
|
||||
}
|
||||
@@ -10,14 +10,14 @@ import kotlin.time.Instant
|
||||
/**
|
||||
* Элемент **durable**-потока диалога.
|
||||
*
|
||||
* Каждое событие несёт [date] — момент эмиссии в UTC. Используется клиентом
|
||||
* как курсор («где остановился») при обрыве/переподключении и для разрешения
|
||||
* порядка при равных timestamps.
|
||||
* Каждое событие несёт [date] — момент эмиссии в UTC (для отображения и
|
||||
* разрешения порядка при равных timestamps).
|
||||
*
|
||||
* Это «целые», сохраняемые события: сообщения ([UserMessage]/[AssistantMessage]),
|
||||
* вызовы тулов ([ToolCall]/[ToolResult]/[ToolFailed]), терминаторы хода
|
||||
* ([Interrupted]/[Error]) и lifecycle ([ConversationClosing]/[CompactionTriggered]).
|
||||
* Их можно перезапросить по курсору (`after`).
|
||||
* Durable-история для перезапроса живёт в
|
||||
* [pw.binom.agentik.journal.JournalStore].
|
||||
*
|
||||
* **Стриминг ответа и маркеры фаз хода — НЕ здесь.** Дельты текста/картинок
|
||||
* и маркеры `Working`/`End` живут в [OnlineEvent] (live-only, не сохраняются).
|
||||
|
||||
@@ -1,40 +1,25 @@
|
||||
package pw.binom.agentik.outbox
|
||||
|
||||
/**
|
||||
* Mutable вариант [OutboxStore] — добавляет producer-операции [reserveOffset]
|
||||
* и [append].
|
||||
* Mutable вариант [OutboxStore] — добавляет producer-операцию [append].
|
||||
*
|
||||
* Предназначен **только для producer'ов** (ChatAgent, ConversationLoop,
|
||||
* ToolDispatcher, sub-agents, A2A-bridge). Consumer'ы принимают read-only
|
||||
* [OutboxStore] — тогда невозможно случайно писать в store из observer'а.
|
||||
*
|
||||
* ## Контракт записи (порядок важен)
|
||||
* ## Контракт записи
|
||||
* ```
|
||||
* val n = outbox.reserveOffset() // 1. забронировать offset
|
||||
* journal.append(record.copy(seq = n)) // 2. сначала состояние
|
||||
* outbox.append(event.copy(offset = n)) // 3. потом событие
|
||||
* journal.append(record) // 1. сначала durable состояние (audit)
|
||||
* outbox.append(event) // 2. потом live-уведомление
|
||||
* ```
|
||||
* «Сначала состояние, потом событие» — инвариант, на котором держится
|
||||
* [OutboxStore.currentCursor]: к моменту, когда событие `n` появилось в
|
||||
* outbox, строка состояния со `seq = n` уже записана.
|
||||
*
|
||||
* Offset **обязан** быть выставлен в [CommonEvent.offset]; store проверяет
|
||||
* строгую монотонность и бросает [IllegalArgumentException] на нарушение.
|
||||
* Событие — это уведомление о том, что durable-факт уже записан. Позицию в
|
||||
* потоке (курсор) outbox не ведёт: монотонный порядок для удалённого
|
||||
* доступа держит sync-слой.
|
||||
*/
|
||||
interface MutableOutboxStore : OutboxStore {
|
||||
|
||||
/**
|
||||
* Забронировать следующий монотонный offset (делегирует в
|
||||
* [OffsetSequencer.reserve]). Вызывается **до** записи состояния.
|
||||
*/
|
||||
suspend fun reserveOffset(): Long
|
||||
|
||||
/**
|
||||
* Положить событие в лог. [CommonEvent.offset] должен быть уже выставлен
|
||||
* (обычно значением из [reserveOffset]).
|
||||
*
|
||||
* **Не идемпотентно** — повторный append с тем же offset'ом нарушает
|
||||
* монотонность и бросит исключение (защита от двойной записи).
|
||||
* Опубликовать событие в live-поток.
|
||||
*/
|
||||
suspend fun append(event: CommonEvent)
|
||||
}
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
package pw.binom.agentik.outbox
|
||||
|
||||
/**
|
||||
* Источник монотонных offset'ов **для одного агента**.
|
||||
*
|
||||
* Один счётчик на агента, сквозной по всем сущностям (conversation + message +
|
||||
* lifecycle): это даёт единый [Cursor] на всё — глобальный курсор (чат
|
||||
* появился/умер/переименован) и per-chat курсор суть просто закладки в одном
|
||||
* потоке offset'ов (как offset одного Kafka-topic'а с ключом `conversationId`).
|
||||
*
|
||||
* **Кто владеет счётчиком**: writer-сторона. В `standalone` это персистентный
|
||||
* счётчик в той же SQLite-БД, что и журнал, — иначе рестарт сервера сбросил бы
|
||||
* offset'ы, а у клиента в локальной БД остался бы старый курсор. Персистентность
|
||||
* даёт дешёвый инкрементальный resume после рестарта; полная инвалидация
|
||||
* (сброс/восстановление БД) закрывается ротацией [epoch].
|
||||
*
|
||||
* **Порядок записи (инвариант)**: сначала пишется строка состояния с
|
||||
* `seq = reserve()`, потом событие с `offset = <тот же>`. Тогда «состояние
|
||||
* с offset ≤ C» гарантированно уже записано в момент чтения снапшота с
|
||||
* курсором C, и всё, что `> C`, придёт потоком.
|
||||
*
|
||||
* **`epoch()`/`current()` — не-`suspend`**: persistent-реализация читает
|
||||
* `(epoch, counter)` один раз при создании (в конструкторе, где и так идёт
|
||||
* синхронный I/O открытия БД) и держит в памяти; на диск пишет только
|
||||
* [reserve]. Это позволяет читать курсор из любого места без корутины.
|
||||
*/
|
||||
interface OffsetSequencer {
|
||||
/**
|
||||
* Идентификатор текущей эпохи счётчика (см. [Cursor.epoch]). Стабилен между
|
||||
* рестартами, пока счётчик персистентный.
|
||||
*/
|
||||
fun epoch(): String
|
||||
|
||||
/** Следующий offset, который будет выдан [reserve] (next offset to assign). */
|
||||
fun current(): Long
|
||||
|
||||
/** Забронировать следующий offset; монотонно возрастает на 1 (durable). */
|
||||
suspend fun reserve(): Long
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package pw.binom.agentik.outbox
|
||||
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.filter
|
||||
|
||||
/**
|
||||
* Live-канал **онлайн-событий** ([OnlineEvent]) диалога — стриминга ответа
|
||||
@@ -27,7 +28,8 @@ interface OnlineOutbox : AutoCloseable {
|
||||
* Подписка на live-поток онлайн-событий диалога [conversationId].
|
||||
* События, эмитнутые до подписки, не приходят.
|
||||
*/
|
||||
fun onlineEvents(conversationId: String): Flow<OnlineEvent>
|
||||
fun onlineEvents(conversationId: String): Flow<OnlineEvent> =
|
||||
onlineEvents().filter { it.conversationId == conversationId }
|
||||
|
||||
/** Освобождает ресурсы. Idempotent. */
|
||||
override fun close()
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
package pw.binom.agentik.outbox
|
||||
|
||||
/**
|
||||
* Курсор клиента вышел за пределы retention'а outbox'а, **или** принадлежит
|
||||
* другой [Cursor.epoch].
|
||||
*
|
||||
* Это **не ошибка выполнения**, а сигнал протокола: «твой курсор мёртв — я не
|
||||
* могу отдать непрерывный поток событий, начиная с него». Клиент обязан:
|
||||
* 1. очистить/пометить свой локальный кэш как устаревший;
|
||||
* 2. запросить у сервера свежий **snapshot состояния** (он вернёт и состояние,
|
||||
* и актуальный [Cursor]);
|
||||
* 3. подписаться на события `after = <cursor из снапшота>` и накатить snapshot,
|
||||
* затем буферизованные дельты.
|
||||
*
|
||||
* Бросается **изнутри** [OutboxStore.events] / [OutboxStore.conversationEvents] /
|
||||
* [OutboxStore.agentEvents] (то есть из Flow, а не отдельной pre-check'ом) — так
|
||||
* проверка делается под тем же lock'ом, что и регистрация подписчика, и не
|
||||
* гоняется с конкурентной эвикцией.
|
||||
*
|
||||
* **Retry-политики НЕ должна этому исключению ретраить** (см.
|
||||
* `ReconnectingOutbox`): повторный connect с тем же курсором даст тот же gap и
|
||||
* превратится в бесконечный цикл. Обработка — resync, не backoff.
|
||||
*/
|
||||
class OutboxGapException(
|
||||
/** Курсор, с которого клиент просил поток (может быть `null` для live-only). */
|
||||
val requested: Cursor?,
|
||||
/** Актуальный курсор сервера (`currentCursor()`). */
|
||||
val current: Cursor,
|
||||
/** Минимальный курсор, с которого ещё можно продолжить поток (`oldestCursor()`). */
|
||||
val oldest: Cursor,
|
||||
) : RuntimeException(
|
||||
"Outbox cursor is out of retention: requested=$requested, " +
|
||||
"oldest=$oldest, current=$current. Re-snapshot the full state."
|
||||
)
|
||||
@@ -5,83 +5,49 @@ import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.coroutines.flow.filterIsInstance
|
||||
|
||||
/**
|
||||
* Bounded-tail лог **durable**-событий агента.
|
||||
* Live-шина **durable**-событий агента.
|
||||
*
|
||||
* Хранит [CommonEvent] — «целые» факты хода ([DurableEvent]) и lifecycle
|
||||
* диалогов ([AgentEvent]). Высокочастотный стриминг ответа (дельты текста и
|
||||
* картинок) сюда **не попадает** — он живёт в [OnlineOutbox] (live-only, не
|
||||
* сохраняется и не реплеится).
|
||||
* картинок) сюда **не попадает** — он живёт в [OnlineOutbox] (live-only,
|
||||
* не сохраняется и не реплеится).
|
||||
*
|
||||
* ## Два уровня хранения
|
||||
* 1. **Этот store** — короткий bounded tail (live SSE + недавний replay),
|
||||
* эвиктится по TTL/cap (implementation-defined).
|
||||
* 2. **Журнал (`:journal-api`)** — полный audit log, никогда не эвиктится.
|
||||
* Source of truth для всего прошлого. Он и есть «полное состояние»,
|
||||
* которое запрашивает клиент при resync'е.
|
||||
* ## Роль
|
||||
* outbox — это **уведомления в моменте**: расширения агента (reflection,
|
||||
* skill mining, sub-agents) и UI подписываются на живой поток. Историю
|
||||
* («что было раньше») отдаёт [pw.binom.agentik.journal.JournalStore] — полный
|
||||
* durable audit. Всё, что нужно для *удалённого* доступа (resume с курсора,
|
||||
* снапшот + дельты, gap detection), — забота sync-слоя поверх journal/outbox,
|
||||
* а не этого интерфейса.
|
||||
*
|
||||
* ## Курсор, а не дата
|
||||
* Позиция в потоке — монотонный [Cursor] `(epoch, offset)`, а не wall-clock
|
||||
* [CommonEvent.date]. Offset уникален и упорядочен даже когда два события
|
||||
* делят одну миллисекунду. `Instant` для этого не годится (лоссов на ничьих),
|
||||
* случайный `id` — тоже (не задаёт порядок записи).
|
||||
*
|
||||
* ## Протокол клиента (гарантия «в итоге корректное состояние»)
|
||||
* ```
|
||||
* // 1. Пробуем продолжить с сохранённого курсора.
|
||||
* try {
|
||||
* outbox.conversationEvents(after = saved, conversationId = id).collect { apply(it) }
|
||||
* } catch (e: OutboxGapException) {
|
||||
* // 2. Курсор мёртв — берём свежий снапшот (состояние + его курсор).
|
||||
* val snap = agent.chatSnapshot(id) // { state, cursor }
|
||||
* clearLocal(); applySnapshot(snap.state)
|
||||
* // 3. Подписка с курсора снапшота; дельты > cursor накатываются поверх.
|
||||
* outbox.conversationEvents(after = snap.cursor, conversationId = id).collect { apply(it) }
|
||||
* }
|
||||
* ```
|
||||
* Точный порядок на стороне сервера/snapshot'а (subscribe-before-snapshot,
|
||||
* буферизация дельт, idempotent apply) описан в `:client/README.md`.
|
||||
*
|
||||
* ## Gap detection
|
||||
* `after != null && after.offset < oldestCursor().offset` (или другой
|
||||
* [Cursor.epoch]) → [OutboxGapException] бросается **изнутри** Flow. Проверка
|
||||
* идёт под тем же lock'ом, что и регистрация подписчика (одним критическим
|
||||
* участком), поэтому не гоняется с конкурентной эвикцией и не теряет события
|
||||
* в окне «snapshot → live».
|
||||
* ## Курсора и replay здесь нет
|
||||
* [CommonEvent.date] — только метка времени для отображения/сортировки.
|
||||
* Подписки отдают события, эмитированные **после** вызова (live-only).
|
||||
* Клиенту, которому нужен пропущенный хвост, следует читать journal.
|
||||
*
|
||||
* ## Read-only
|
||||
* Интерфейс предоставляет только чтение. Запись — [MutableOutboxStore].
|
||||
*
|
||||
* ## Подписки
|
||||
* Каждый вызов [events] / [conversationEvents] / [agentEvents] — **новая
|
||||
* независимая подписка** (cold Flow). `after == null` → только live (события
|
||||
* с момента вызова). Иные consumer'ы видят тот же live-tail; каждая подписка —
|
||||
* своя проекция.
|
||||
* независимая подписка** (cold Flow). Иные consumer'ы видят тот же live-tail;
|
||||
* каждая подписка — своя проекция.
|
||||
*/
|
||||
interface OutboxStore : AutoCloseable {
|
||||
|
||||
/**
|
||||
* Подписка на события.
|
||||
*
|
||||
* - `after == null` → **только live** (события с момента вызова, replay
|
||||
* буфера не отдаётся);
|
||||
* - `after != null` → сначала **catchup** всех буферизованных событий с
|
||||
* `offset > after.offset` (по возрастанию offset), затем live.
|
||||
*
|
||||
* @throws OutboxGapException изнутри Flow, если [after] старше
|
||||
* [oldestCursor] (retention gap) или принадлежит другой эпохе.
|
||||
* Живой поток событий. События, эмитнутые до подписки, не приходят.
|
||||
*/
|
||||
fun events(after: Cursor?): Flow<CommonEvent>
|
||||
fun events(): Flow<CommonEvent>
|
||||
|
||||
/**
|
||||
* Подписка только на conversation-события ([CommonEvent.Conversation]).
|
||||
*
|
||||
* - `conversationId == null` → все диалоги;
|
||||
* - `conversationId != null` → только этот диалог.
|
||||
*
|
||||
* Семантика [after] и `gap` идентична [events].
|
||||
*/
|
||||
fun conversationEvents(after: Cursor?, conversationId: String? = null): Flow<CommonEvent.Conversation> =
|
||||
events(after)
|
||||
fun conversationEvents(conversationId: String? = null): Flow<CommonEvent.Conversation> =
|
||||
events()
|
||||
.filterIsInstance<CommonEvent.Conversation>()
|
||||
.let { filtered ->
|
||||
if (conversationId == null) filtered
|
||||
@@ -91,32 +57,9 @@ interface OutboxStore : AutoCloseable {
|
||||
/**
|
||||
* Подписка только на agent-события ([CommonEvent.Agent] — создание/удаление/
|
||||
* переименование диалога).
|
||||
*
|
||||
* Семантика [after] и `gap` идентична [events].
|
||||
*/
|
||||
fun agentEvents(after: Cursor?): Flow<CommonEvent.Agent> =
|
||||
events(after).filterIsInstance<CommonEvent.Agent>()
|
||||
|
||||
/**
|
||||
* Актуальный курсор: offset последнего **записанного** события
|
||||
* (`lastOffset`). Это «commit point» снапшота: состояние со `seq <= cursor.offset`
|
||||
* уже в БД, всё, что `> cursor.offset`, придёт потоком.
|
||||
*
|
||||
* Клиент берёт его из снапшота либо напрямую перед подпиской.
|
||||
*/
|
||||
suspend fun currentCursor(): Cursor
|
||||
|
||||
/**
|
||||
* Минимальный курсор, с которого ещё можно продолжить поток без разрыва.
|
||||
*
|
||||
* - `after.offset >= oldestCursor().offset` → replay возможен;
|
||||
* - `after.offset < oldestCursor().offset` → [OutboxGapException].
|
||||
*
|
||||
* Для никогда не эвиктировавшего буфера равен offset'у последнего события
|
||||
* (т.е. «истории нет, но резумиться с конца можно»), а не `-1`: клиент,
|
||||
* догнавший состояние до рестарта, продолжает инкрементально.
|
||||
*/
|
||||
suspend fun oldestCursor(): Cursor
|
||||
fun agentEvents(): Flow<CommonEvent.Agent> =
|
||||
events().filterIsInstance<CommonEvent.Agent>()
|
||||
|
||||
override fun close()
|
||||
}
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
package pw.binom.agentik.outbox
|
||||
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
|
||||
/**
|
||||
* [OffsetSequencer] поверх персистентного [CursorStore] — production-счётчик
|
||||
* событий агента.
|
||||
*
|
||||
* На создании читает сохранённый [Cursor] (`epoch` + next-offset) синхронно и
|
||||
* держит его в памяти. Позиция переживает рестарт процесса, поэтому обычный
|
||||
* рестарт сервера **не меняет** `epoch` и offset'ы остаются монотонными —
|
||||
* клиент продолжает инкрементально (см. [Cursor.epoch] KDoc), а не получает
|
||||
* gap на каждой перезагрузке.
|
||||
*
|
||||
* ### Стартовый offset
|
||||
*
|
||||
* Если хранилище пустое (первый запуск / апгрейд БД, где `message.seq` уже
|
||||
* накоплен), новый `epoch` генерируется сразу, а `next` берётся из [initialNext]
|
||||
* — по умолчанию `0`, но апгрейд должен передать `maxSeq + 1` журнала, иначе
|
||||
* новые offset'ы столкнутся с уже записанными `seq`. Новый `epoch` при этом
|
||||
* корректно заставляет клиентов сделать однократный resync.
|
||||
*
|
||||
* ### Ротация epoch
|
||||
*
|
||||
* Смена «мира» (сброс/восстановление БД) выполняется вызывающим: очисти
|
||||
* [CursorStore] — следующий старт сгенерирует новый `epoch`.
|
||||
*/
|
||||
class PersistentOffsetSequencer(
|
||||
private val store: CursorStore,
|
||||
private val initialNext: () -> Long = { 0L },
|
||||
private val newEpoch: () -> String = { Cursor.newEpoch() },
|
||||
) : OffsetSequencer {
|
||||
|
||||
private val mutex = Mutex()
|
||||
private val epoch: String
|
||||
private var next: Long
|
||||
|
||||
init {
|
||||
val saved = store.load()
|
||||
if (saved == null) {
|
||||
epoch = newEpoch()
|
||||
next = initialNext()
|
||||
// Фиксируем epoch сразу, чтобы он не «прыгал» до первого события.
|
||||
store.save(Cursor(epoch = epoch, offset = next))
|
||||
} else {
|
||||
epoch = saved.epoch
|
||||
next = saved.offset
|
||||
}
|
||||
}
|
||||
|
||||
override fun epoch(): String = epoch
|
||||
|
||||
override fun current(): Long = next
|
||||
|
||||
override suspend fun reserve(): Long = mutex.withLock {
|
||||
val assigned = next
|
||||
next = assigned + 1
|
||||
store.save(Cursor(epoch = epoch, offset = next))
|
||||
assigned
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user