Введение монотонного 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) удалён.
27 KiB
ТЗ: Синхронизация клиент-сервер для чат-приложения с локальными и удалёнными ассистентами
0. Контекст и цель
Есть приложение-чат. В чатах пользователь общается с ассистентами. Ассистенты бывают:
- удалённые — работают на сервере;
- локальные — работают прямо на устройстве клиента.
Приложение должно:
- работать офлайн (пользователь может читать и писать, пока нет сети);
- отрисовывать UI мгновенно, не дожидаясь сети;
- бесшовно синхронизироваться, когда сеть появляется;
- использовать один и тот же код для клиента и сервера там, где это возможно;
- не хранить события вечно — журнал событий компактится.
Ключевая архитектурная идея: клиент рисует UI исключительно из своей локальной базы. Сетевые запросы нужны только для синхронизации, а не для отрисовки.
1. Основные понятия (глоссарий)
| Термин | Определение |
|---|---|
| Событие (Event) | Атомарный факт изменения состояния. Append-only. Имеет монотонный курсор seq. |
| Журнал событий (Event Log) | Упорядоченная по seq последовательность событий. Append-only. Подвергается компакции. |
| Материализованное состояние (Materialized State) | Текущее состояние домена (чаты, сообщения), полученное применением событий. Изменяемое. Каждая строка имеет last_seq — курсор последнего события, которое её изменило. |
| Курсор (seq) | Монотонно возрастающее целое число. Глобально уникальное. Присваивается сервером при записи события. |
| Состояние на курсоре N | Множество строк материализованного состояния, у которых last_seq <= N. Это «снимок» состояния на момент N. |
| Апдейты после N | Множество событий из журнала, у которых seq > N. |
| Full resync | Полная замена локального состояния клиента состоянием с сервера. Не merge, а replace. |
| Live sync | Инкрементальная догрузка событий после известного курсора. |
| Компакция | Удаление старых событий из журнала, которые уже «схлопнуты» в материализованное состояние. |
2. Инварианты системы
Эти инварианты должны соблюдаться всегда. Если хоть один нарушен — система некорректна.
- Курсор монотонен.
seqстрого возрастает. Никаких дыр, никаких сбросов. - Журнал append-only. События не изменяются и не удаляются, кроме как через компакцию.
- Материализация консистентна журналу. Для любого
N:apply(events where seq <= N) == SELECT * FROM state WHERE last_seq <= N. Это значит, что материализованное состояние — это не «что-то отдельное», а результат применения журнала. - События самодостаточны. Каждое событие несёт полный payload изменённой сущности, а не дельту. Это нужно, чтобы клиент мог применить событие к незнакомой сущности.
- Full resync = replace. При полной синхронизации клиент заменяет своё локальное состояние, а не мержит.
- Клиент отрисовывает только из локальной базы. Никакой запрос к серверу не блокирует UI.
- Компакция не удаляет события, которые ещё нужны активным клиентам. Либо удаляет, но тогда клиент обязан сделать full resync.
3. Модель данных
3.1. На сервере
3.1.1. Журнал событий
EventLog:
seq : int64, PK, монотонный
event_id : UUID, уникальный идентификатор события
type : enum (ChatCreated, MessageAppended, MessageEdited, MessageDeleted,
ToolCallRequested, ToolCallSucceeded, ToolCallFailed, ...)
payload : JSON / бинарный blob с полным состоянием изменённой сущности
created_at : timestamp
origin : device_id / user_id / assistant_id (кто породил)
Индексы: PK(seq), INDEX(created_at).
3.1.2. Материализованное состояние
Отдельные таблицы под каждую сущность. Примеры:
Chat:
id : UUID, PK
title : string
created_at : timestamp
deleted : bool
last_seq : int64 -- курсор последнего события, изменившего строку
Message:
id : UUID, PK
chat_id : UUID, FK -> Chat.id
role : enum (user, assistant, tool_call, tool_result, error)
content : blob
parent_id : UUID, nullable (для ветвлений)
deleted : bool
last_seq : int64
created_at : timestamp
Индексы: PK(id), INDEX(chat_id, last_seq), INDEX(last_seq).
Важно: поле last_seq — это не «версия строки» в смысле MVCC. Это «на каком событии строка стала такой, какая она сейчас». Строка всегда хранит только актуальную версию.
3.1.3. Метаданные компакции
CompactionState:
min_available_seq : int64 -- самый старый курсор, который ещё можно запросить
last_compacted_at : timestamp
Если клиент запрашивает курсор < min_available_seq — сервер отвечает «курсор протух, делай full resync».
3.2. На клиенте
Клиент хранит те же таблицы, что и сервер (материализованное состояние), плюс дополнительно:
SyncState:
last_seq : int64 -- курсор, до которого клиент синхронизирован
last_sync_at : timestamp
PendingEvent:
local_id : UUID, PK
type : enum
payload : JSON
created_at : timestamp
status : enum (pending, sent, failed)
PendingEvent — это события, которые клиент сгенерировал локально (пользователь написал сообщение, локальный ассистент ответил), но которые ещё не подтверждены сервером.
Важно: PendingEvent не имеет seq — он появится только после подтверждения сервером.
4. Абстракции кода
4.1. Общие для клиента и сервера
// Доменные события — общие
sealed interface DomainEvent {
val eventId: UUID
val type: EventType
val payload: EventPayload
}
// Материализованное состояние — общее
interface StateStore {
fun readState(upToSeq: Long): StateSnapshot
fun applyEvent(event: LoggedEvent) // применяет событие к состоянию
fun lastSeq(): Long
}
// Журнал — общий интерфейс чтения
interface EventLog {
fun readUpdates(afterSeq: Long, limit: Int): List<LoggedEvent>
fun lastSeq(): Long
}
// Событие с назначенным курсором
data class LoggedEvent(
val seq: Long,
val eventId: UUID,
val type: EventType,
val payload: EventPayload,
val createdAt: Instant,
)
4.2. Только сервер
interface EventLogWriter : EventLog {
// Атомарно: назначает seq, пишет в журнал, применяет к материализации
fun append(event: DomainEvent): LoggedEvent
// Компакция
fun compact(upToSeq: Long)
}
4.3. Только клиент
interface EventLogReplica : EventLog {
// Применить событие от сервера к локальному состоянию
fun applyRemote(event: LoggedEvent)
// Заменить всё локальное состояние состоянием с сервера
fun replaceState(snapshot: StateSnapshot, upToSeq: Long)
// Локальные (ещё не подтверждённые) события
fun pendingEvents(): List<PendingEvent>
fun markPendingAsSent(localId: UUID, seq: Long)
fun markPendingAsFailed(localId: UUID)
}
4.4. Ассистент — общая абстракция
Ключевая идея прозрачности: ассистент — это просто генератор событий. Клиент не знает, локальный он или удалённый.
interface Assistant {
// Запускает генерацию, возвращает поток событий
fun run(input: RunInput): Flow<DomainEvent>
}
class RemoteAssistant(...) : Assistant {
override fun run(input: RunInput): Flow<DomainEvent> {
// Стримит события от сервера через WebSocket
}
}
class LocalAssistant(...) : Assistant {
override fun run(input: RunInput): Flow<DomainEvent> {
// Генерирует события локально (например, вызывает локальную LLM)
}
}
Клиент подписывается на поток событий и применяет их так же, как события от сервера. Локальный ассистент порождает PendingEvent, который потом уходит на сервер и подтверждается.
5. Протокол синхронизации
5.1. Точки входа (API)
GET /sync/state?upToSeq=N
- Возвращает материализованное состояние на момент N:
SELECT * FROM state WHERE last_seq <= N. - Если N не указан — возвращает актуальное состояние.
- Если N <
min_available_seq— возвращает ошибкуcursor_expiredс указанием актуальногоlast_seq. - Ответ:
{ snapshot: StateSnapshot, upToSeq: N, currentSeq: M }, где M — текущий максимальный курсор.
GET /sync/updates?afterSeq=N&limit=K
- Возвращает события
WHERE seq > N ORDER BY seq LIMIT K. - Если N <
min_available_seq— ошибкаcursor_expired. - Ответ:
{ events: [LoggedEvent], hasMore: bool, currentSeq: M }.
WS /sync/live?afterSeq=N
- WebSocket. Сервер пушит события
seq > Nв реальном времени. - Если N <
min_available_seq— сервер закрывает соединение с кодомcursor_expired.
POST /sync/events
- Клиент отправляет
PendingEvent(ы) на сервер. - Сервер валидирует, назначает
seq, применяет, возвращаетLoggedEvent(ы). - Идемпотентность:
eventId(UUID). Повторная отправка того жеeventId— no-op, возвращается уже назначенныйseq.
5.2. Алгоритм синхронизации на клиенте
sync():
1. Прочитать local lastSeq.
2. Попробовать GET /sync/updates?afterSeq=lastSeq&limit=K.
3. Если ответ cursor_expired:
3.1. GET /sync/state (без upToSeq) → получить актуальное состояние и currentSeq.
3.2. replaceState(snapshot, currentSeq).
3.3. lastSeq = currentSeq.
3.4. Перейти к шагу 5.
4. Если ответ ок:
4.1. Для каждого события в ответе: applyRemote(event).
4.2. lastSeq = max(seq в ответе).
4.3. Если hasMore — повторить с шага 2.
5. Отправить все PendingEvent через POST /sync/events.
5.1. Для каждого подтверждённого: markPendingAsSent(localId, seq).
5.2. Для каждого неподтверждённого: markPendingAsFailed(localId).
6. Открыть WS /sync/live?afterSeq=lastSeq.
6.1. При получении события: applyRemote(event), lastSeq = event.seq.
6.2. При разрыве: вернуться к шагу 2.
Важно: шаг 3 (full resync) заменяет состояние, а не мержит. Все локальные данные, которых нет в снапшоте, удаляются. Pending-события при этом сохраняются и отправляются после resync (шаг 5).
5.3. Обработка локальных событий (пользователь пишет сообщение)
userSendsMessage(chatId, content):
1. Создать DomainEvent(MessageAppended, {chatId, content, role: user, ...}).
2. Сохранить в PendingEvent со status = pending.
3. Применить событие к локальному состоянию (оптимистично), чтобы UI сразу показал сообщение.
4. Запустить sync() в фоне.
5. Когда сервер подтвердит — markPendingAsSent(localId, seq).
Если сервер вернул ошибку — markPendingAsFailed(localId), откатить состояние.
5.4. Обработка локального ассистента
localAssistantRuns(chatId, input):
1. Запустить Assistant.run(input), получить Flow<DomainEvent>.
2. Для каждого события в потоке:
2.1. Сохранить в PendingEvent со status = pending.
2.2. Применить к локальному состоянию.
3. Запустить sync() — события уйдут на сервер, получат seq, станут частью журнала.
Клиент не различает события от пользователя, от локального ассистента и от удалённого. Все они идут через PendingEvent → сервер → LoggedEvent.
6. Компакция журнала
6.1. Зачем
Журнал растёт бесконечно. Старые события уже «схлопнуты» в материализованное состояние. Их можно удалить.
6.2. Как
Периодический фоновый процесс на сервере:
- Определить
compaction_seq= минимальный курсор, который ещё нужен активным клиентам. Если неизвестно — использоватьcurrent_seq - safety_margin. - Удалить события
WHERE seq <= compaction_seq. - Обновить
CompactionState.min_available_seq = compaction_seq + 1.
6.3. Что делать клиенту с протухшим курсором
Если lastSeq < min_available_seq:
- Не пытаться догрузить апдейты (их нет).
- Сделать full resync:
GET /sync/state→replaceState→ продолжить live sync.
Это не ошибка, это штатный сценарий. Клиент просто получает актуальное состояние и продолжает жить.
6.4. Ключевое правило
Full resync = replace, не merge. Если клиент мержит, удалённые и отредактированные сущности останутся в старом виде. Если заменяет — всё консистентно.
7. Обработка редактирования и удаления
7.1. Редактирование
Событие MessageEdited несёт полный новый content сообщения.
- В журнал пишется событие с
seq = S. - В материализации строка обновляется на месте:
content = newContent,last_seq = S.
Клиент на живом курсоре N < S:
- Получает событие
MessageEditedиз журнала. - Применяет: обновляет
contentу себя, ставитlast_seq = S.
Клиент на протухшем курсоре, resync на M > S:
- В снапшоте видит сообщение уже с новым content.
- Событие
Sне приходит (оно ≤ M). - Всё консистентно.
7.2. Удаление
Событие MessageDeleted. В материализации строка либо физически удаляется, либо помечается deleted = true с last_seq = S.
Рекомендуется физическое удаление, потому что replace при resync всё равно уберёт строку. Tombstones нужны только если требуется показывать «сообщение удалено» в UI.
Клиент на живом курсоре N < S:
- Получает событие
MessageDeleted. - Удаляет строку у себя.
Клиент на протухшем курсоре, resync на M > S:
- В снапшоте строки нет.
- replace убирает её у клиента.
- Событие
Sне приходит. - Всё консистентно.
8. Гарантии и краевые случаи
8.1. Идемпотентность
Каждое событие имеет eventId (UUID). Применение события с уже известным eventId — no-op. Это защищает от:
- повторной отправки
PendingEvent; - повторного применения события при реконнекте;
- дублирования в live sync.
8.2. Порядок событий
Клиент применяет события в порядке seq. Если пришло событие с seq > lastSeq + 1, значит есть пропуск — клиент должен догрузить пропущенные через GET /sync/updates?afterSeq=lastSeq.
8.3. Конфликты
Конфликты не разрешаются «в общем виде». Правила:
- Сообщения append-only (редактирование = новое событие, не мутация).
- Правки от разных устройств одного пользователя — сервер применяет в порядке поступления, последняя побеждает (last-write-wins по
seq). - Ветвления (regenerate) — через
parent_id, а не через мутацию.
8.4. Офлайн-запись
Пользователь пишет офлайн → событие в PendingEvent → применяется локально → UI показывает. При появлении сети → sync → сервер назначает seq → markPendingAsSent.
Если сервер отверг событие (валидация не прошла) → markPendingAsFailed → клиент откатывает локальное изменение.
8.5. Мультиустройство
У каждого клиента есть device_id. События помечаются origin. При синке клиент не применяет свои же события повторно (идемпотентность по eventId решает это автоматически).
8.6. Большие снапшоты
GET /sync/state может вернуть много данных. Решения:
- Пагинация:
GET /sync/state?upToSeq=N&chatId=X— по одному чату. - Стриминг: HTTP chunked / gRPC streaming.
- Сжатие (gzip / zstd).
Для чата с ассистентом обычно достаточно per-chat снапшотов.
9. Что должен реализовать кодовый агент
9.1. Сервер
- Хранилище журнала событий (
EventLog): append-only, монотонныйseq, компакция. - Хранилище материализованного состояния (
StateStore): таблицыChat,Message, полеlast_seq. - Логика применения события (
applyEvent): обновляет материализацию, ставитlast_seq. - Атомарная операция
append: в одной транзакции назначаетseq, пишет в журнал, применяет к материализации. - HTTP API:
/sync/state,/sync/updates,/sync/events. - WebSocket:
/sync/live. - Фоновый компактор: периодически удаляет старые события, обновляет
min_available_seq. - Идемпотентность: таблица
processed_event_idsили проверка поeventId.
9.2. Клиент
- Локальное хранилище состояния (
StateStore): те же таблицы, что на сервере. - Локальное хранилище
PendingEvent: очередь несинхронизированных событий. - Логика применения события (
applyEvent): общая с сервером (один код). - Логика
replaceState: полная замена локального состояния. - Sync-клиент: реализует алгоритм из раздела 5.2.
- WebSocket-клиент: live sync.
- UI: рисует исключительно из локального
StateStore. Никогда не ждёт сеть. - Ассистенты:
LocalAssistantиRemoteAssistantчерез общий интерфейсAssistant, возвращающийFlow<DomainEvent>.
9.3. Общее
- Модель
DomainEventс самодостаточным payload. - Интерфейсы
EventLog,StateStore,Assistant. - Логика сериализации/десериализации событий.
- UUID-генерация для
eventId. - Логика идемпотентности по
eventId.
10. Чего делать НЕ надо
- Не делать MVCC. Строки хранят только текущую версию. История — в журнале, но она компактится.
- Не делать tombstones, если не нужно показывать «удалено». Физическое удаление + replace при resync решают всё.
- Не мержить при full resync. Только replace.
- Не запрашивать сервер для отрисовки. UI читает только локальную базу.
- Не различать локального и удалённого ассистента на уровне клиента. Оба —
Assistant, возвращающийFlow<DomainEvent>. - Не хранить счётчик в БД отдельно.
seq— это либо sequence в БД, либо ULID в событии. Отдельный «счётчик» — лишняя сущность. - Не бояться, что «старый курсор протух». Это штатный сценарий: full resync.
11. Критерии готовности
- Пользователь может писать офлайн, UI обновляется мгновенно.
- При появлении сети события уходят на сервер, получают
seq, синхронизируются. - Второй клиент видит изменения в реальном времени.
- Локальный ассистент работает так же, как удалённый, с точки зрения клиента.
- Компакция журнала не ломает синхронизацию: клиент с протухшим курсором делает full resync и продолжает.
- Редактирование и удаление сообщений обрабатываются консистентно во всех сценариях.
- Full resync заменяет состояние, не оставляя «фантомных» строк.
- Идемпотентность: повторная отправка события не создаёт дублей.
12. Резюме идеи в одном абзаце
Есть журнал событий с монотонным курсором и материализованное состояние, где каждая строка помечена курсором последнего изменившего её события. Состояние на курсоре N — это строки с last_seq <= N. Апдейты после N — это события с seq > N. Клиент хранит локальную копию состояния и свой lastSeq. Для синхронизации он либо догружает апдейты (если курсор жив), либо заменяет состояние целиком (если курсор протух из-за компакции). В обоих случаях результат консистентен: клиент видит актуальные данные, не видит «шума» про отредактированные/удалённые сущности, которые были до его курсора, и продолжает live sync с нового курсора. Локальный и удалённый ассистенты — просто генераторы событий, неотличимые для клиента.