138 lines
6.3 KiB
Markdown
138 lines
6.3 KiB
Markdown
# `:event-store` — bounded-tail event log (KMP)
|
||
|
||
## Что это
|
||
|
||
Двухуровневое хранилище событий агента. Этот модуль — **короткий
|
||
bounded tail** для live-SSE и недавнего replay. Полный audit log
|
||
живёт в `:message-store-api` (никогда не эвиктится, source of truth).
|
||
|
||
Три принципа:
|
||
|
||
1. **Tail управляет TTL сам.** Никаких `prune`/`cleanup` методов наружу —
|
||
implementation решает, когда выкинуть старый event. Caller'ы не
|
||
могут забыть cleanup.
|
||
2. **Catchup + live в одном Flow.** `events(after)` сначала отдаёт
|
||
буферизованный диапазон, потом переключается на live tail — клиент
|
||
не должен знать, где у него "разрыв".
|
||
3. **Read-only контракт для consumer'ов.** Запись через
|
||
[MutableEventStore], чтение через [EventStore]. Compile-time
|
||
гарантия что observer не сможет писать в store.
|
||
|
||
## Где используется
|
||
|
||
- `:standalone` ChatAgent — append через `MutableOutboxStore` (заменяет
|
||
текущий `agentEvents: MutableSharedFlow` + `persistAgentEvent`).
|
||
- `:server` Routes.kt — `/events/all` SSE endpoint читает через
|
||
`EventStore.events(after)`.
|
||
- Будущий `:android-agent` core — same интерфейс для локального
|
||
bounded tail без dedicated server connection.
|
||
|
||
## Архитектура
|
||
|
||
```
|
||
┌─ :event-store (этот модуль) ────────────────────────┐
|
||
│ Bounded tail с auto-TTL: │
|
||
│ • append(event) ← producer │
|
||
│ • events(after): Flow ← consumer │
|
||
│ • earliestEventDate() для gap detection │
|
||
│ TTL/cap eviction — внутри impl │
|
||
└───────────────────────────────────────────────────┘
|
||
▲ gap detected
|
||
│
|
||
┌─ :message-store-api (полный audit log) ───────────┐
|
||
│ MessageStore: query(after, before, limit) │
|
||
│ Никогда не эвиктится. Source of truth. │
|
||
└───────────────────────────────────────────────────┘
|
||
```
|
||
|
||
**Reconnect pattern** (caller делает):
|
||
|
||
```kotlin
|
||
val earliest = eventStore.earliestEventDate()
|
||
if (client.lastSeen < earliest) {
|
||
// gap: догоняем через :message-store-api
|
||
val gap = messageStore.query(after = client.lastSeen, before = earliest)
|
||
applyAll(gap)
|
||
client.lastSeen = gap.last().createdAt
|
||
}
|
||
eventStore.events(after = client.lastSeen).collect { apply(it) }
|
||
```
|
||
|
||
## API
|
||
|
||
### `OutboxStore` (read-only, для consumer'ов)
|
||
|
||
```kotlin
|
||
interface EventStore : AutoCloseable {
|
||
fun events(after: Instant?): Flow<CommonEvent>
|
||
fun conversationEvents(
|
||
after: Instant?,
|
||
conversationId: String? = null, // null = все диалоги
|
||
): Flow<CommonEvent.Conversation>
|
||
fun agentEvents(after: Instant?): Flow<CommonEvent.Agent>
|
||
suspend fun earliestEventDate(): Instant // non-null: now() для пустого буфера
|
||
override fun close()
|
||
}
|
||
```
|
||
|
||
**Семантика фильтров**:
|
||
- `conversationEvents(null)` — все диалоги.
|
||
- `conversationEvents("c-123")` — один конкретный диалог.
|
||
- `agentEvents(...)` — только lifecycle (Created/Deleted/Renamed).
|
||
|
||
Все три возвращают **типизированные** subtype'ы [CommonEvent], так что
|
||
caller'у не нужно `.filterIsInstance` на клиентской стороне.
|
||
|
||
### `MutableEventStore : EventStore` (для producer'ов)
|
||
|
||
```kotlin
|
||
interface MutableEventStore : EventStore {
|
||
suspend fun append(event: CommonEvent)
|
||
}
|
||
```
|
||
|
||
**Append НЕ идемпотентен**: [CommonEvent] не имеет уникального id,
|
||
retry даст дубликат. Для exactly-once — dedup через
|
||
`:message-store-api` (там есть монотонный `id`).
|
||
|
||
**Silently evicted**: implementation может выкинуть event сразу после
|
||
append по TTL/cap. Producer не должен полагаться на то, что event
|
||
дойдёт до клиента, если он вне retention window.
|
||
|
||
## Когда использовать какой интерфейс
|
||
|
||
| Caller | Method |
|
||
|---|---|
|
||
| Server `/events/all` SSE (mixed) | `events(after)` |
|
||
| Server `/conversations/{id}/events` SSE | `conversationEvents(after, conversationId)` |
|
||
| Server `/agent/events` SSE (lifecycle only) | `agentEvents(after)` |
|
||
| Admin dashboard (lifecycle) | `agentEvents(after)` |
|
||
| Parent orchestrator (mixed) | `events(after)` |
|
||
| Тесты | `events(after)` + projection через фильтр |
|
||
|
||
## Как добавить новый implementation
|
||
|
||
1. Создать класс с конструктором и lifecycle (`close()` обязан
|
||
освободить ресурсы).
|
||
2. Реализовать минимум: append (с TTL eviction), events (Flow с
|
||
catchup + live), earliestEventDate (non-null Instant, now() если
|
||
буфер пуст).
|
||
3. Для persistent impl: SQL/ksqlite таблица с индексом по date,
|
||
вставка = `INSERT OR IGNORE` для дедупликации на уровне БД
|
||
(если в схеме будет id).
|
||
|
||
## Текущее состояние
|
||
|
||
- ✅ Interface дизайн (`OutboxStore` + `MutableOutboxStore`)
|
||
- ✅ KMP build (jvm + linuxX64 + mingwX64)
|
||
- ⏳ Нет implementations (next: `InMemoryEventStore` для тестов)
|
||
- ⏳ Не интегрирован в `:standalone`/`:server`
|
||
|
||
## Зависимости
|
||
|
||
- `:proto` (api) — тип `CommonEvent` (3 AgentEvent + 9 Conversation.Event вариантов).
|
||
- `kotlinx-coroutines-core` (api) — `Flow`.
|
||
|
||
Никаких `kotlinx-serialization`, `kotlin-logging`, platform-specific
|
||
зависимостей — этот модуль намеренно minimal.
|