From 92b76c4e3c3b17c487f08dfebc1afea1bfa84b0f Mon Sep 17 00:00:00 2001 From: subochev Date: Fri, 2 Oct 2026 01:05:46 +0300 Subject: [PATCH] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D1=8F?= =?UTF-8?q?=D0=B5=D1=82=20:sync-core=20=E2=80=94=20=D1=80=D0=B5=D1=84?= =?UTF-8?q?=D0=B5=D1=80=D0=B5=D0=BD=D1=81=D0=BD=D0=B0=D1=8F=20=D1=80=D0=B5?= =?UTF-8?q?=D0=B0=D0=BB=D0=B8=D0=B7=D0=B0=D1=86=D0=B8=D1=8F=20SYNC-SYSTEM.?= =?UTF-8?q?md.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Новый KMP-модуль :sync-core реализует спеку SYNC-SYSTEM.md с нуля, без опоры на существующие :outbox-api / :journal-api / :client. Цель — собрать работающую модель «append-only event journal + материализация + replace-resync», проверить инварианты тестами, и уже потом думать, как это распределить по существующим абстракциям. Архитектура (§3-§5 спеки): - Cursor(epoch, number) — пара (поколение, счётчик); @JvmInline value class; монотонно возрастает внутри эпохи, при wipe/restart эпоха меняется и клиент обязан делать replaceState. - EventLog / EventLogWriter / EventLogReplica — append-only журнал + материализация на стороне клиента (replica ничего не хранит в журнале, только state + sync_state + pending). - StateStore / Reducer — детерминированная свёртка событий в снапшот ChatState. - PendingEvent + LocalId — offline-write очередь: PENDING → SENT/FAILED. - SyncEngine — оркестратор: fetchUpdates → applyRemote → fetchState → replaceState → postPending. - SyncTransport — четыре endpoint'а (fetchUpdates / fetchState / subscribeLive / postPending), сейчас реализован InProcessTransport (in-memory), задел под HTTP+WS. ksqlite-бэкенд (commonMain, ksqlite 0.1.4): - SqliteEventLog — append-only с UNIQUE(event_id) для идемпотентности. - SqliteEventLogReplica — клиентская сторона (processed_event_id, pending_event, sync_state singleton-row). - SqliteStateStore — chat_state / message_state + replaceState. - SqliteSyncBundle — фабрика для тестов. CursorExpired split (§6.3 спеки): - WRONG_EPOCH — сервер сменил эпоху (wipe/restore/миграция). - TOO_OLD — компакция унесла события ниже floor. Оба → клиент обязан сделать replaceState (sync() делает это сам). EventLogWriter.beginNewEpoch(newEpoch): Cursor — атомарная смена эпохи на сервере: DELETE event_log + reset sqlite_sequence + UPDATE compaction_state, в одной транзакции. Сборка: jvm + linuxX64 (mingwX64 компилируется, тесты на linux хосте пропускаются — ksqlite имеет нативные бинарники только под эти три). Тесты (74, commonTest, проходят на jvm и linuxX64): - CursorTest (14) — парсинг, валидация, isAfter/isBefore/compareTo. - EpochMismatchTest (6) — WRONG_EPOCH / TOO_OLD → full resync. - InvariantsTest — 7 инвариантов спеки на in-memory бэкенде. - IdempotencyTest — повторный applyRemote по eventId = no-op. - EditDeleteTest — message edit/delete через события. - CompactionAndOrderTest — compact сдвигает minAvailableCursor. - OfflineWriteTest — pending отправляется при следующем sync(). - AssistantTest — LocalAssistant возвращает Flow. - KsqliteSyncSpecTest (15) — все спец-тесты на ksqlite. - KsqlitePersistenceTest — file-based persistence. Существующий код (:outbox-api / :journal-api / :client / :server) не трогаем — это чистая референсная реализация для последующей миграции. --- build.gradle.kts | 1 + settings.gradle.kts | 7 + sync-core/README.md | 46 +++ sync-core/build.gradle.kts | 41 ++ .../pw/binom/agentik/sync/AssistantImpl.kt | 60 +++ .../kotlin/pw/binom/agentik/sync/Cursor.kt | 128 ++++++ .../binom/agentik/sync/CursorExpiredReason.kt | 12 + .../pw/binom/agentik/sync/DefaultReducer.kt | 129 ++++++ .../kotlin/pw/binom/agentik/sync/Domain.kt | 217 ++++++++++ .../kotlin/pw/binom/agentik/sync/Errors.kt | 23 ++ .../kotlin/pw/binom/agentik/sync/Events.kt | 78 ++++ .../pw/binom/agentik/sync/Interfaces.kt | 160 ++++++++ .../pw/binom/agentik/sync/SyncEngine.kt | 108 +++++ .../kotlin/pw/binom/agentik/sync/Transport.kt | 119 ++++++ .../binom/agentik/sync/ksqlite/EventCodec.kt | 41 ++ .../pw/binom/agentik/sync/ksqlite/Schema.kt | 169 ++++++++ .../agentik/sync/ksqlite/SqliteEventLog.kt | 385 ++++++++++++++++++ .../sync/ksqlite/SqliteEventLogReplica.kt | 319 +++++++++++++++ .../agentik/sync/ksqlite/SqliteStateStore.kt | 339 +++++++++++++++ .../agentik/sync/ksqlite/SqliteSyncBundle.kt | 100 +++++ .../agentik/sync/memory/InMemoryEventLog.kt | 277 +++++++++++++ .../agentik/sync/memory/InProcessTransport.kt | 156 +++++++ .../binom/agentik/sync/testing/SyncBundle.kt | 59 +++ .../sync/ksqlite/KsqlitePersistenceTest.kt | 55 +++ .../sync/ksqlite/KsqliteSyncSpecTest.kt | 229 +++++++++++ .../binom/agentik/sync/test/AssistantTest.kt | 88 ++++ .../sync/test/CompactionAndOrderTest.kt | 157 +++++++ .../pw/binom/agentik/sync/test/CursorTest.kt | 123 ++++++ .../binom/agentik/sync/test/EditDeleteTest.kt | 114 ++++++ .../agentik/sync/test/EpochMismatchTest.kt | 142 +++++++ .../agentik/sync/test/IdempotencyTest.kt | 91 +++++ .../binom/agentik/sync/test/InvariantsTest.kt | 166 ++++++++ .../agentik/sync/test/OfflineWriteTest.kt | 106 +++++ .../pw/binom/agentik/sync/test/Samples.kt | 63 +++ .../sync/test/memory/InMemorySyncBundle.kt | 64 +++ 35 files changed, 4372 insertions(+) create mode 100644 sync-core/README.md create mode 100644 sync-core/build.gradle.kts create mode 100644 sync-core/src/commonMain/kotlin/pw/binom/agentik/sync/AssistantImpl.kt create mode 100644 sync-core/src/commonMain/kotlin/pw/binom/agentik/sync/Cursor.kt create mode 100644 sync-core/src/commonMain/kotlin/pw/binom/agentik/sync/CursorExpiredReason.kt create mode 100644 sync-core/src/commonMain/kotlin/pw/binom/agentik/sync/DefaultReducer.kt create mode 100644 sync-core/src/commonMain/kotlin/pw/binom/agentik/sync/Domain.kt create mode 100644 sync-core/src/commonMain/kotlin/pw/binom/agentik/sync/Errors.kt create mode 100644 sync-core/src/commonMain/kotlin/pw/binom/agentik/sync/Events.kt create mode 100644 sync-core/src/commonMain/kotlin/pw/binom/agentik/sync/Interfaces.kt create mode 100644 sync-core/src/commonMain/kotlin/pw/binom/agentik/sync/SyncEngine.kt create mode 100644 sync-core/src/commonMain/kotlin/pw/binom/agentik/sync/Transport.kt create mode 100644 sync-core/src/commonMain/kotlin/pw/binom/agentik/sync/ksqlite/EventCodec.kt create mode 100644 sync-core/src/commonMain/kotlin/pw/binom/agentik/sync/ksqlite/Schema.kt create mode 100644 sync-core/src/commonMain/kotlin/pw/binom/agentik/sync/ksqlite/SqliteEventLog.kt create mode 100644 sync-core/src/commonMain/kotlin/pw/binom/agentik/sync/ksqlite/SqliteEventLogReplica.kt create mode 100644 sync-core/src/commonMain/kotlin/pw/binom/agentik/sync/ksqlite/SqliteStateStore.kt create mode 100644 sync-core/src/commonMain/kotlin/pw/binom/agentik/sync/ksqlite/SqliteSyncBundle.kt create mode 100644 sync-core/src/commonMain/kotlin/pw/binom/agentik/sync/memory/InMemoryEventLog.kt create mode 100644 sync-core/src/commonMain/kotlin/pw/binom/agentik/sync/memory/InProcessTransport.kt create mode 100644 sync-core/src/commonMain/kotlin/pw/binom/agentik/sync/testing/SyncBundle.kt create mode 100644 sync-core/src/commonTest/kotlin/pw/binom/agentik/sync/ksqlite/KsqlitePersistenceTest.kt create mode 100644 sync-core/src/commonTest/kotlin/pw/binom/agentik/sync/ksqlite/KsqliteSyncSpecTest.kt create mode 100644 sync-core/src/commonTest/kotlin/pw/binom/agentik/sync/test/AssistantTest.kt create mode 100644 sync-core/src/commonTest/kotlin/pw/binom/agentik/sync/test/CompactionAndOrderTest.kt create mode 100644 sync-core/src/commonTest/kotlin/pw/binom/agentik/sync/test/CursorTest.kt create mode 100644 sync-core/src/commonTest/kotlin/pw/binom/agentik/sync/test/EditDeleteTest.kt create mode 100644 sync-core/src/commonTest/kotlin/pw/binom/agentik/sync/test/EpochMismatchTest.kt create mode 100644 sync-core/src/commonTest/kotlin/pw/binom/agentik/sync/test/IdempotencyTest.kt create mode 100644 sync-core/src/commonTest/kotlin/pw/binom/agentik/sync/test/InvariantsTest.kt create mode 100644 sync-core/src/commonTest/kotlin/pw/binom/agentik/sync/test/OfflineWriteTest.kt create mode 100644 sync-core/src/commonTest/kotlin/pw/binom/agentik/sync/test/Samples.kt create mode 100644 sync-core/src/commonTest/kotlin/pw/binom/agentik/sync/test/memory/InMemorySyncBundle.kt diff --git a/build.gradle.kts b/build.gradle.kts index 764a98e..24a07be 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -55,6 +55,7 @@ val moduleDescriptions: Map = mapOf( "agentik-cli" to "agentik :agentik-cli — JVM CLI-клиент (JLine) к /agentik: REPL + slash-команды + стрим SSE.", // "agentik-tui" to "agentik :agentik-tui — Compose-for-Mosaic TUI-клиент (отключён 2026-09-17)." "standalone" to "agentik :standalone — single-jar HTTP-сервер со всеми транспортами (AG-UI/A2A/:proto), SQLite, памятью, скилами и SOUL.", + "sync-core" to "agentik :sync-core — референсная реализация спеки SYNC-SYSTEM.md: журнал событий с монотонным курсором + материализация + replace-resync. KMP (jvm/linuxX64/mingwX64), ksqlite-бэкенд, in-memory бэкенд в commonMain.", ) rootProject.extra.set("moduleDescriptions", moduleDescriptions) diff --git a/settings.gradle.kts b/settings.gradle.kts index 39ef57f..8b4a90b 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -146,3 +146,10 @@ include(":agent-api") // SkillMiningComponent, слушающий outbox (Event.ConversationClosing / // Event.CompactionTriggered) и добывающий новые скилы через SkillMiner. include(":skill-mining") +// Референсная реализация спеки `SYNC-SYSTEM.md` — журнал событий с +// монотонным курсором + материализация + replace-resync, единый протокол +// для клиента и сервера. KMP с jvm/linuxX64/mingwX64 целями (ksqlite- +// ограничение). Тесты спеки в commonTest, интеграция с ksqlite — в jvmTest. +// Существующий код (outbox/journal/client/server) не трогаем; это чистый +// референс для последующей миграции. +include(":sync-core") diff --git a/sync-core/README.md b/sync-core/README.md new file mode 100644 index 0000000..12137d0 --- /dev/null +++ b/sync-core/README.md @@ -0,0 +1,46 @@ +# `:sync-core` + +Референсная реализация ТЗ [`SYNC-SYSTEM.md`](../SYNC-SYSTEM.md) в изоляции от +существующего кода `:outbox-api` / `:journal-api` / `:client` / `:server`. +Цель — проверить, что спека действительно работает, и выработать API для +переиспользования как в in-process (встроенный агент), так и в удалённом +(HTTP/WebSocket) сценариях. + +## Что здесь + +| Слой | Где | Что | +|---|---|---| +| Domain types | `commonMain` | `DomainEvent` (sealed), `LoggedEvent`, `PendingEvent`, `Chat`/`Message`/`ChatState`/`StateSnapshot`, `CompactionState`, `SyncState` | +| Интерфейсы | `commonMain` | `EventLog` / `EventLogWriter` / `EventLogReplica`, `StateStore`, `Reducer`, `Assistant`, `SyncTransport` | +| In-memory бэкенд | `commonMain` | `InMemoryEventLog` (одновременно `Writer` + `Replica`), `InMemoryPendingQueue`, `InMemoryCompactionState`, `InMemorySyncState`, `DefaultChatReducer` | +| SyncEngine | `commonMain` | Алгоритм §5.2 спеки (`sync()` + live-подписка) | +| Transport | `commonMain` | `InProcessTransport` — имитация HTTP/WS поверх in-memory writer'а | +| Assistant | `commonMain` | `Assistant` интерфейс + `LocalAssistant` (in-process генератор) + `RemoteAssistant` (заглушка под HTTP) | +| Tests | `commonTest` | `SyncSpec` (abstract) + спец-тесты под все 7 инвариантов и ключевые сценарии §8 спеки | +| ksqlite-бэкенд | `commonMain` | `SqliteEventLog` / `SqliteStateStore` / `SqlitePendingQueue` / `SqliteCompactionState` / `SqliteSyncState` | +| ksqlite интеграционные тесты | `jvmTest` | `KsqliteSyncSpecTest` — тот же `SyncSpec`, но поверх in-memory SQLite-коннекшна | + +## Границы + +* Не реализует HTTP/WS/SSE (это отдельный слой поверх `SyncTransport`). +* Не реализует LLM (assistant'ы — детерминированные генераторы). +* Не реализует streaming-протокол ответа (стриминг дельт — отдельная + концепция, в спеке его нет). +* Не реализует мультидевайс-race (`origin` помечает события, но спека молчит + о том, как разрешать конкурентные правки одного чата с двух устройств; + last-write-wins по `seq` зашит в редьюсере). + +## Сборка + +```bash +./gradlew :sync-core:build # компиляция + все тесты +./gradlew :sync-core:jvmTest # только JVM (включая ksqlite интеграцию) +./gradlew :sync-core:allTests # все цели +``` + +## Статус + +Спека реализована вчерне, инварианты §2 покрыты тестами в `commonTest`. После +стабилизации API и ksqlite-слоя возможна миграция существующих +`:outbox-api`/`:journal-api`/`:client`/`:server` на эту модель (или нет — тогда +`:sync-core` останется референсом и песочницей для новых идей). diff --git a/sync-core/build.gradle.kts b/sync-core/build.gradle.kts new file mode 100644 index 0000000..5129b8e --- /dev/null +++ b/sync-core/build.gradle.kts @@ -0,0 +1,41 @@ +plugins { + alias(libs.plugins.kotlin.multiplatform) + alias(libs.plugins.kotlin.serialization) +} + +// Референсная реализация ТЗ `SYNC-SYSTEM.md` — журнал событий с монотонным +// курсором + материализация (`Chat`/`Message` с `last_seq`), единый протокол +// для клиента и сервера. KMP, всё в commonMain/commonTest где это возможно; +// ksqlite-реализация — в commonMain (ksqlite публикует KMP-варианты для всех +// десктоп-целей), интеграционные тесты под реальной SQLite — в jvmTest. +// +// Цели сборки: +// - commonMain/commonTest содержат pure-Kotlin типы, интерфейсы, in-memory +// реализации и спец-тесты (SyncSpec). Работают на всех 9 KMP-целях. +// - jvmMain + jvmTest дополнительно содержат ksqlite-бэкенд (он же публикуется +// для linuxX64/mingwX64, но запускать с реальной SQLite-схемой удобнее под JVM). +// +// Почему нет iOS/macOS целей: ksqlite не публикует варианты для них. Если +// когда-нибудь понадобится, цели добавляются одной строкой. + +kotlin { + jvmToolchain(21) + + jvm() + linuxX64() + mingwX64() + + sourceSets { + commonMain.dependencies { + implementation(libs.ksqlite) + implementation(libs.kotlinx.serialization.json) + implementation(libs.kotlinx.coroutines.core) + } + commonTest.dependencies { + implementation(kotlin("test")) + implementation(libs.kotlinx.coroutines.test) + // для файлового персистентного теста — KMP-обёртка вокруг fs. + implementation(libs.kotlinx.io.core) + } + } +} diff --git a/sync-core/src/commonMain/kotlin/pw/binom/agentik/sync/AssistantImpl.kt b/sync-core/src/commonMain/kotlin/pw/binom/agentik/sync/AssistantImpl.kt new file mode 100644 index 0000000..e89dc50 --- /dev/null +++ b/sync-core/src/commonMain/kotlin/pw/binom/agentik/sync/AssistantImpl.kt @@ -0,0 +1,60 @@ +package pw.binom.agentik.sync + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import kotlin.time.Clock + +/** + * Локальный ассистент — генератор доменных событий в процессе клиента + * (§4.4 спеки). + * + * Контракт: + * - `run(input)` возвращает холодный [Flow]. Для каждого [DomainEvent] в + * потоке клиент должен вызвать [EventLogReplica.enqueuePending] (или + * аналог на клиенте) — он оптимистично применяет событие к локальной + * материализации и добавляет его в очередь на отправку. + * - `Flow` завершается, когда ассистент закончил генерацию. + * + * Сама логика генерации (вызов модели, парсинг, tool calls) делается + * конкретной реализацией через [flowBuilder]. Здесь — абстракция. + */ +class LocalAssistant( + private val flowBuilder: suspend (RunInput) -> Flow, +) : Assistant { + override fun run(input: RunInput): Flow = flow { + flowBuilder(input).collect { e -> + emit(e) + } + } +} + +/** + * Удобный конструктор для тестов: возвращает готовый [Flow] фиксированных + * событий. Не использовать в production — это test-fixture. + */ +fun scriptedAssistant(events: List): Assistant = LocalAssistant { _ -> + kotlinx.coroutines.flow.flowOf(*events.toTypedArray()) +} + +/** + * RemoteAssistant — генератор, который **не пишет события сам**, а стримит + * их от сервера через [SyncTransport.subscribeLive]. Эта заглушка существует + * чтобы проиллюстрировать «ассистент — это просто генератор событий» + * (§4.4 спеки): клиент не различает локального и удалённого. + * + * Полная реализация (подключение к WS-ручке ассистента конкретного + * провайдера) выходит за рамки спеки синхронизации. + */ +fun remoteAssistant( + transport: SyncTransport, + after: suspend () -> Cursor, +): Assistant = Assistant { _ -> + flow { + transport.subscribeLive(after()).collect { e -> + // Для каждого серверного события, которое пришло в live-канал, + // считаем его «ответом ассистента» — отдаём клиенту как + // DomainEvent (payload без cursor/createdAt). + emit(e.payload) + } + } +} \ No newline at end of file diff --git a/sync-core/src/commonMain/kotlin/pw/binom/agentik/sync/Cursor.kt b/sync-core/src/commonMain/kotlin/pw/binom/agentik/sync/Cursor.kt new file mode 100644 index 0000000..aacfd8f --- /dev/null +++ b/sync-core/src/commonMain/kotlin/pw/binom/agentik/sync/Cursor.kt @@ -0,0 +1,128 @@ +package pw.binom.agentik.sync + +import kotlinx.serialization.KSerializer +import kotlinx.serialization.Serializable +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlin.jvm.JvmInline + +/** + * Курсор журнала (§3.1.3 спеки) — пара `(epoch, number)`, однозначно + * идентифицирующая позицию в логе событий. + * + * - **epoch** (String) — «поколение» журнала. Не меняется при обычной работе. + * Сервер может его сменить (например, при wipe DB, restore из бэкапа, или + * миграции на новую схему) — после этого клиент со старой эпохой обязан + * сделать full resync (см. §6.3 спеки). Не может содержать `-` (используется + * как разделитель в строковом представлении). + * - **number** (ULong) — монотонный счётчик внутри эпохи. Присваивается + * сервером при `append` (§2.1 спеки: «строго возрастает, без дыр»). + * + * Строковое представление — `"-"`, например `"v1-2a"`. + * Сериализуется и в JSON, и в SQLite TEXT как одна строка. + * + * Сериализация через [CursorSerializer] (зарегистрирован как `@Serializable(with=…)`). + */ +@Serializable(with = CursorSerializer::class) +@JvmInline +value class Cursor private constructor(private val raw: String) { + + constructor(epoch: String, number: ULong) : this(buildRaw(epoch, number)) + + /** Поколение журнала. Меняется при wipe/restore/миграции сервера. */ + val epoch: String + get() = raw.substring(0, raw.indexOf('-')) + + /** Монотонный счётчик внутри эпохи. */ + val number: ULong + get() = raw.substring(raw.indexOf('-') + 1).toULong(16) + + /** + * Следующий курсор в этой же эпохе. `number + 1`. + * + * **Без проверки на переполнение** — `ULong.MAX_VALUE.next()` тихо + * оборачивается в 0. Сервер должен следить за `lastCursor.number` и + * заранее вызывать [EventLogWriter.beginNewEpoch] (см. readme модуля). + */ + fun next(): Cursor = Cursor(epoch = epoch, number = number + 1uL) + + /** Строковое представление `"-"`. */ + fun toRawString(): String = raw + + /** + * Лексикографическое сравнение — сначала по `epoch`, потом по `number`. + * + * ⚠️ Курсоры из **разных эпох** несравнимы по смыслу «до/после»: + * `compareTo` всё равно их упорядочит (для сортировки), но использовать + * `cursor > other` для проверки свежести в разных эпохах бессмысленно. + * Семантическая проверка «X свежее Y в той же эпохе» — + * `cursor.epoch == other.epoch && cursor.number > other.number` + * либо [isAfter]/[isBefore]. + */ + operator fun compareTo(other: Cursor): Int { + val epochCmp = epoch.compareTo(other.epoch) + return if (epochCmp != 0) epochCmp else number.compareTo(other.number) + } + + /** «X свежее Y в той же эпохе». Возвращает `null`, если эпохи разные. */ + fun isAfter(other: Cursor): Boolean? = + if (epoch != other.epoch) null else number > other.number + + /** «X старше Y в той же эпохе». Возвращает `null`, если эпохи разные. */ + fun isBefore(other: Cursor): Boolean? = + if (epoch != other.epoch) null else number < other.number + + override fun toString(): String = "Cursor(epoch=$epoch, number=$number)" + + companion object { + /** Стартовый курсор: «не синхронизировано». Соответствует `epoch="v1", number=0`. */ + val ZERO: Cursor = Cursor("v1", 0uL) + + /** Разобрать курсор из строкового представления. Бросает `IllegalArgumentException` при ошибке. */ + fun fromString(raw: String): Cursor { + require(raw.length > 3) { "Cursor too short: \"$raw\"" } + val sep = raw.indexOf('-') + require(sep > 0) { "Cursor missing epoch/number separator: \"$raw\"" } + require(raw.indexOf('-', sep + 1) == -1) { + "Cursor has multiple separators: \"$raw\"" + } + val numStr = raw.substring(sep + 1) + val num = numStr.toULongOrNull(16) + require(num != null) { "Cursor number part is not hex: \"$raw\"" } + return Cursor(raw) + } + + private fun buildRaw(epoch: String, number: ULong): String { + require(epoch.isNotEmpty()) { "epoch must be non-empty" } + require(epoch.none { it == '-' }) { "epoch must not contain '-': \"$epoch\"" } + return "$epoch-${number.toString(16)}" + } + } +} + +/** + * Курсоры из **разных эпох несравнимы** по смыслу «до/после»: после + * [EventLogWriter.beginNewEpoch] старые курсоры клиента невалидны, и + * единственный допустимый порядок — заменить локальное состояние. Семантика + * «свежее» — [Cursor.isAfter]/[Cursor.isBefore] либо явная проверка + * `epoch == && number >`. + */ + +/** + * kotlinx-serialization `KSerializer` для [Cursor]. Кодируется как + * примитивная строка формата `"-"`. + */ +object CursorSerializer : KSerializer { + override val descriptor: SerialDescriptor = + PrimitiveSerialDescriptor("pw.binom.agentik.sync.Cursor", PrimitiveKind.STRING) + + override fun serialize(encoder: Encoder, value: Cursor) { + encoder.encodeString(value.toRawString()) + } + + override fun deserialize(decoder: Decoder): Cursor = + Cursor.fromString(decoder.decodeString()) +} diff --git a/sync-core/src/commonMain/kotlin/pw/binom/agentik/sync/CursorExpiredReason.kt b/sync-core/src/commonMain/kotlin/pw/binom/agentik/sync/CursorExpiredReason.kt new file mode 100644 index 0000000..bd59988 --- /dev/null +++ b/sync-core/src/commonMain/kotlin/pw/binom/agentik/sync/CursorExpiredReason.kt @@ -0,0 +1,12 @@ +package pw.binom.agentik.sync + +/** + * Причина, по которой курсор клиента больше не валиден. + * + * - [WRONG_EPOCH] — клиент работает со старой эпохой. Сервер сделал wipe / + * restore / миграцию. Клиент должен сделать full resync (replaceState). + * - [TOO_OLD] — клиент в правильной эпохе, но запрашивает события старше + * [CompactionState.minAvailableCursor]. Сервер удалил старые события при + * compaction. Клиент должен сделать full resync. + */ +enum class CursorExpiredReason { WRONG_EPOCH, TOO_OLD } diff --git a/sync-core/src/commonMain/kotlin/pw/binom/agentik/sync/DefaultReducer.kt b/sync-core/src/commonMain/kotlin/pw/binom/agentik/sync/DefaultReducer.kt new file mode 100644 index 0000000..72ebf85 --- /dev/null +++ b/sync-core/src/commonMain/kotlin/pw/binom/agentik/sync/DefaultReducer.kt @@ -0,0 +1,129 @@ +package pw.binom.agentik.sync + +/** + * Редьюсер по умолчанию для [ChatState]. Применяет событие к состоянию, + * обновляя затронутую строку и проставляя ей `lastCursor = event.cursor`. + * + * Правила (§2.3 спеки + §7.1 + §7.2): + * - `ChatCreated` → добавляет чат. + * - `ChatRenamed` → обновляет title чата. + * - `ChatDeleted` → удаляет чат (см. §7.2). + * - `MessageAppended` → добавляет сообщение в список чата. + * - `MessageEdited` → заменяет content сообщения (несёт полный новый). + * - `MessageDeleted` → **физически удаляет** из списка (см. §7.2). + * - `ToolCall*` → добавляет сообщение с ролью TOOL_CALL / TOOL_RESULT / ERROR. + */ +object DefaultChatReducer : Reducer { + + override fun reduce(state: ChatState, event: LoggedEvent): ChatState { + val p = event.payload + return when (p) { + is DomainEvent.ChatCreated -> state.copy( + chats = state.chats + (p.chatId to Chat( + id = p.chatId, + title = p.title, + createdAt = p.createdAt, + deleted = false, + lastCursor = event.cursor, + )), + ) + + is DomainEvent.ChatRenamed -> state.copy( + chats = state.chats.update(p.chatId) { old -> + old.copy(title = p.newTitle, lastCursor = event.cursor) + }, + ) + + is DomainEvent.ChatDeleted -> state.copy( + chats = state.chats - p.chatId, + ) + + is DomainEvent.MessageAppended -> state.copy( + messages = state.messages.appendMessage(p.chatId, Message( + id = p.messageId, + chatId = p.chatId, + role = p.role, + content = p.content, + parentId = p.parentId, + deleted = false, + lastCursor = event.cursor, + createdAt = p.createdAt, + )), + ) + + is DomainEvent.MessageEdited -> state.copy( + messages = state.messages.updateMessages(p.chatId) { list -> + list.map { m -> + if (m.id == p.messageId) m.copy(content = p.newContent, lastCursor = event.cursor) else m + } + }, + ) + + is DomainEvent.MessageDeleted -> state.copy( + messages = state.messages.updateMessages(p.chatId) { list -> + list.filter { it.id != p.messageId } + }, + ) + + is DomainEvent.ToolCallRequested -> state.copy( + messages = state.messages.appendMessage(p.chatId, Message( + id = p.messageId, + chatId = p.chatId, + role = MessageRole.TOOL_CALL, + content = "${p.toolName}(${p.toolArgs})", + parentId = null, + deleted = false, + lastCursor = event.cursor, + createdAt = event.createdAt, + )), + ) + + is DomainEvent.ToolCallSucceeded -> state.copy( + messages = state.messages.appendMessage(p.chatId, Message( + id = p.messageId, + chatId = p.chatId, + role = MessageRole.TOOL_RESULT, + content = p.result, + parentId = p.toolCallId, + deleted = false, + lastCursor = event.cursor, + createdAt = event.createdAt, + )), + ) + + is DomainEvent.ToolCallFailed -> state.copy( + messages = state.messages.appendMessage(p.chatId, Message( + id = p.messageId, + chatId = p.chatId, + role = MessageRole.ERROR, + content = p.message, + parentId = p.toolCallId, + deleted = false, + lastCursor = event.cursor, + createdAt = event.createdAt, + )), + ) + } + } +} + +private inline fun Map.update(key: K, block: (V) -> V): Map { + val old = this[key] ?: return this + return this + (key to block(old)) +} + +private fun Map>.updateMessages( + chatId: ChatId, + block: (List) -> List, +): Map> { + val old = this[chatId] ?: emptyList() + return this + (chatId to block(old)) +} + +private fun Map>.appendMessage( + chatId: ChatId, + message: Message, +): Map> { + val old = this[chatId] ?: emptyList() + return this + (chatId to (old + message)) +} diff --git a/sync-core/src/commonMain/kotlin/pw/binom/agentik/sync/Domain.kt b/sync-core/src/commonMain/kotlin/pw/binom/agentik/sync/Domain.kt new file mode 100644 index 0000000..3e0ca8a --- /dev/null +++ b/sync-core/src/commonMain/kotlin/pw/binom/agentik/sync/Domain.kt @@ -0,0 +1,217 @@ +package pw.binom.agentik.sync + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlin.time.Instant + +/** + * Доменное событие журнала (§3.1.1 спеки). + * + * Это «что произошло», без курсора и без идентификатора записи в журнале — + * идентификатор и курсор появляются при записи (см. [LoggedEvent]). Каждое + * событие самодостаточно (§2.4 спеки): несёт полный снимок изменённой + * сущности в полях payload'а, чтобы клиент мог применить событие к + * незнакомой строке. + * + * [eventId] — стабильный UUID, генерируется **продюсером** (клиентом для + * pending-event'ов, сервером для server-side событий). Используется для + * идемпотентности (§8.1 спеки). + */ +@Serializable +sealed interface DomainEvent { + + /** Уникальный идентификатор события (UUID строкой). */ + val eventId: String + + /** Чат создан. */ + @Serializable + @SerialName("chat_created") + data class ChatCreated( + override val eventId: String, + val chatId: ChatId, + val title: String, + val createdAt: Instant, + ) : DomainEvent + + /** Чат переименован. */ + @Serializable + @SerialName("chat_renamed") + data class ChatRenamed( + override val eventId: String, + val chatId: ChatId, + val newTitle: String, + ) : DomainEvent + + /** Чат помечен удалённым. Replace при resync уберёт его у клиента. */ + @Serializable + @SerialName("chat_deleted") + data class ChatDeleted( + override val eventId: String, + val chatId: ChatId, + ) : DomainEvent + + /** Сообщение добавлено. [parentId] — для ветвлений (regenerate), `null` в линейной истории. */ + @Serializable + @SerialName("message_appended") + data class MessageAppended( + override val eventId: String, + val messageId: MessageId, + val chatId: ChatId, + val role: MessageRole, + val content: String, + val parentId: MessageId? = null, + val createdAt: Instant, + ) : DomainEvent + + /** Сообщение отредактировано — несёт **полный новый content** (§7.1 спеки). */ + @Serializable + @SerialName("message_edited") + data class MessageEdited( + override val eventId: String, + val messageId: MessageId, + val chatId: ChatId, + val newContent: String, + ) : DomainEvent + + /** Сообщение удалено физически (см. §7.2 — рекомендуется физическое удаление). */ + @Serializable + @SerialName("message_deleted") + data class MessageDeleted( + override val eventId: String, + val messageId: MessageId, + val chatId: ChatId, + ) : DomainEvent + + /** Ассистент начал вызов тула. */ + @Serializable + @SerialName("tool_call_requested") + data class ToolCallRequested( + override val eventId: String, + val messageId: MessageId, + val chatId: ChatId, + val toolName: String, + val toolArgs: String, + ) : DomainEvent + + /** Тул успешно вернул результат. */ + @Serializable + @SerialName("tool_call_succeeded") + data class ToolCallSucceeded( + override val eventId: String, + val messageId: MessageId, + val chatId: ChatId, + val toolCallId: MessageId, + val toolName: String, + val result: String, + ) : DomainEvent + + /** Исполнение тула упало (handler exception, args не парсятся и т.п.). */ + @Serializable + @SerialName("tool_call_failed") + data class ToolCallFailed( + override val eventId: String, + val messageId: MessageId, + val chatId: ChatId, + val toolCallId: MessageId, + val toolName: String, + val message: String, + ) : DomainEvent +} + +/** + * Снимок сообщения в материализованном состоянии. [lastCursor] — курсор, на + * котором эта строка последний раз менялась. Используется для отсечки + * «состояние на курсоре N» (все строки с `lastCursor.number <= N.number` при + * `lastCursor.epoch == current_epoch`). + */ +@Serializable +data class Message( + val id: MessageId, + val chatId: ChatId, + val role: MessageRole, + val content: String, + val parentId: MessageId?, + val deleted: Boolean, + val lastCursor: Cursor, + val createdAt: Instant, +) + +@Serializable +enum class MessageRole { USER, ASSISTANT, TOOL_CALL, TOOL_RESULT, ERROR } + +/** Снимок чата в материализованном состоянии. [lastCursor] — курсор, на котором строка менялась последний раз. */ +@Serializable +data class Chat( + val id: ChatId, + val title: String, + val createdAt: Instant, + val deleted: Boolean, + val lastCursor: Cursor, +) + +/** + * Состояние на момент курсора [lastCursor]: все чаты и сообщения с + * `lastCursor.number <= that.number` и `lastCursor.epoch == that.epoch`. + * Поскольку материализация живёт только в текущей эпохе, эпоха всегда равна + * текущей — см. [CompactionState.currentEpoch]. + */ +@Serializable +data class ChatState( + val chats: Map = emptyMap(), + val messages: Map> = emptyMap(), +) { + /** + * Максимальный курсор по всем строкам в **текущей эпохе** (на которой мы + * сейчас находимся). Для пустого состояния — [Cursor.ZERO]. + * + * Используется как точка отсечки для снапшота («состояние на курсоре N» = + * строки с `lastCursor.number <= N.number` при `lastCursor.epoch == N.epoch`). + */ + val lastCursor: Cursor + get() { + val epoch = chats.values.firstOrNull()?.lastCursor?.epoch + ?: messages.values.firstOrNull { it.isNotEmpty() }?.first()?.lastCursor?.epoch + ?: return Cursor.ZERO + var max: ULong = 0uL + for (c in chats.values) { + if (c.lastCursor.epoch != epoch) continue + if (c.lastCursor.number > max) max = c.lastCursor.number + } + for (ms in messages.values) for (m in ms) { + if (m.lastCursor.epoch != epoch) continue + if (m.lastCursor.number > max) max = m.lastCursor.number + } + return Cursor(epoch, max) + } +} + +/** + * Снапшот состояния, возвращаемый `GET /sync/state`. Не несёт `upToCursor` + * явно — сервер гарантирует, что все строки в [chats] и [messages] имеют + * `lastCursor.number <= upToCursor.number` в текущей эпохе, и + * `upToCursor` = [ChatState.lastCursor]. + */ +@Serializable +data class StateSnapshot( + val state: ChatState, +) + +// value class требует @JvmInline на JVM, но @JvmInline недоступен на Native. +// Используем обычный data class — потеряем zero-cost обёртку, но сохраним +// KMP-совместимость. Если/когда JetBrains починит cross-target value classes, +// можно будет вернуть. +@Serializable +data class ChatId(val raw: String) { + override fun toString(): String = raw +} + +@Serializable +data class MessageId(val raw: String) { + override fun toString(): String = raw +} + +/** Идентификатор локального pending-event'а (UUID строкой). */ +@Serializable +data class LocalId(val raw : String) { + override fun toString(): String = raw +} diff --git a/sync-core/src/commonMain/kotlin/pw/binom/agentik/sync/Errors.kt b/sync-core/src/commonMain/kotlin/pw/binom/agentik/sync/Errors.kt new file mode 100644 index 0000000..8d67168 --- /dev/null +++ b/sync-core/src/commonMain/kotlin/pw/binom/agentik/sync/Errors.kt @@ -0,0 +1,23 @@ +package pw.binom.agentik.sync + +sealed class SyncException(message: String, cause: Throwable? = null) : RuntimeException(message, cause) + +class CursorExpiredException( + val requested: Cursor, + val currentEpoch: String, + val minAvailable: Cursor, + val current: Cursor, + val reason: CursorExpiredReason, +) : SyncException( + "cursor $requested expired (reason=$reason, current=$current, minAvailable=$minAvailable)", +) + +class EventRejectedException( + val eventId: String, + reason: String, +) : SyncException("event $eventId rejected: $reason") + +class SeqMonotonicityViolationException( + val previous: Long, + val attempted: Long, +) : SyncException("seq must be strictly increasing (previous=$previous, attempted=$attempted)") diff --git a/sync-core/src/commonMain/kotlin/pw/binom/agentik/sync/Events.kt b/sync-core/src/commonMain/kotlin/pw/binom/agentik/sync/Events.kt new file mode 100644 index 0000000..75befa3 --- /dev/null +++ b/sync-core/src/commonMain/kotlin/pw/binom/agentik/sync/Events.kt @@ -0,0 +1,78 @@ +package pw.binom.agentik.sync + +import kotlinx.serialization.Serializable +import kotlin.time.Instant + +/** + * Событие в журнале — [DomainEvent] + назначенный сервером [Cursor] (§3.1.1 спеки). + * + * Курсор сервера строго возрастает внутри эпохи (§2.1), никаких дыр. При смене + * эпохи (wipe / restore / миграция) счётчик обнуляется и меняется `epoch`. + * + * `epoch` берётся из [Cursor]; `seq` оставлен как convenience — это + * `cursor.number`, нужен редко (например, для отладки или для индексов в БД). + */ +data class LoggedEvent( + val cursor: Cursor, + val eventId: String, + val payload: DomainEvent, + val createdAt: Instant, + val origin: String? = null, +) { + /** `cursor.number`. Удобно для SQL-предикатов и индексов. */ + val seq: ULong get() = cursor.number +} + +/** + * Локальное событие, отправленное клиентом, но ещё не подтверждённое сервером + * (§3.2 спеки). Не имеет `cursor` — он появится после `POST /sync/events`. + * + * На клиенте переходит `PENDING → SENT` (получили `cursor`) или `PENDING → + * FAILED` (сервер отверг — клиент откатывает локальное изменение). + * + * `sentCursor` непустой только в статусе [PendingStatus.SENT]: это курсор, + * под которым событие зафиксировано в журнале сервера. + */ +data class PendingEvent( + val localId: LocalId, + val payload: DomainEvent, + val createdAt: Instant, + val status: PendingStatus, + val sentCursor: Cursor? = null, +) + +enum class PendingStatus { + /** Создан локально, на сервер ещё не ушёл. */ + PENDING, + /** Сервер принял, `cursor` назначен (см. [PendingEvent.sentCursor]). */ + SENT, + /** Сервер отверг (валидация / правки). Клиент откатывает локальное изменение. */ + FAILED, +} + +/** + * Состояние синхронизации клиента (§3.2 спеки). «До какого курсора мы + * синхронизированы» + «когда последний раз был успешный sync». + * + * Курсор `Cursor.ZERO` (т.е. `(epoch, 0)`) означает «ничего не синхронизировано» — + * клиент должен сделать full resync перед подпиской на live. + */ +data class SyncState( + val cursor: Cursor = Cursor.ZERO, + val lastSyncAt: Instant? = null, +) + +/** + * Метаданные компакции и эпохи журнала (§3.1.3 спеки). + * + * `minAvailableCursor` — самый старый курсор, который сервер может replay'ить. + * Сервер стирает события с `cursor < minAvailableCursor` через `compact(upTo)`. + * + * `currentEpoch` дублирует [minAvailableCursor.epoch] для удобства: позволяет + * клиенту проверять эпоху без раскрутки курсора. + */ +data class CompactionState( + val currentEpoch: String, + val minAvailableCursor: Cursor, + val lastCompactedAt: Instant?, +) diff --git a/sync-core/src/commonMain/kotlin/pw/binom/agentik/sync/Interfaces.kt b/sync-core/src/commonMain/kotlin/pw/binom/agentik/sync/Interfaces.kt new file mode 100644 index 0000000..ddcb4cc --- /dev/null +++ b/sync-core/src/commonMain/kotlin/pw/binom/agentik/sync/Interfaces.kt @@ -0,0 +1,160 @@ +package pw.binom.agentik.sync + +import kotlin.time.Instant + +/** + * Общий интерфейс чтения журнала событий (§4.1 спеки). + * + * `readUpdates(after, limit)` — replay-чтение «события с `cursor > after`, + * не старше `limit` штук». `lastCursor()` — текущая вершина журнала + * (= [EventLogWriter.append] последнего события). + * + * Сервер и клиент имеют одинаковое представление о чтении, различаются + * только операциями записи ([EventLogWriter] vs [EventLogReplica]). + */ +interface EventLog : AutoCloseable { + + /** + * Возвращает события `cursor > after` в порядке возрастания `cursor`, + * не более `limit` штук. Если событий больше — на стороне вызывающего + * остаётся `lastCursor()` и он повторяет запрос (см. SyncEngine.sync). + * + * Поведение при `after`: + * - `after.cursor.number < minAvailableCursor.number` при совпадающей + * эпохе → бросает [CursorExpiredException] с [CursorExpiredReason.TOO_OLD]. + * - `after.cursor.epoch != currentEpoch` → бросает [CursorExpiredException] + * с [CursorExpiredReason.WRONG_EPOCH]. + */ + suspend fun readUpdates(after: Cursor, limit: Int): List + + /** Текущая вершина журнала. `Cursor.ZERO` если журнал пуст. */ + suspend fun lastCursor(): Cursor + + /** Метаданные компакции и текущей эпохи. */ + suspend fun compaction(): CompactionState + + override fun close() +} + +/** + * Серверная сторона журнала (§4.2 спеки). Добавляет атомарную запись, + * компакцию и смену эпохи. + * + * Контракт [append]: + * 1. Назначить `cursor` (монотонно, под мьютексом). + * 2. В одной транзакции записать событие в журнал **и** применить его к + * материализованному состоянию (§2.3 спеки: «materialization is + * consistent with journal»). Если транзакция не удалась — ни событие, + * ни state-row не должны остаться. + * + * Контракт [compact]: + * 1. Удалить события `cursor.number <= upTo.number`. + * 2. Обновить [CompactionState.minAvailableCursor] = `upTo.next()`. + * + * Контракт [beginNewEpoch]: + * 1. Атомарно стереть все события журнала. + * 2. Установить новую текущую эпоху. + * 3. Сбросить `sqlite_sequence` для autoincrement-колонки. + * 4. Вернуть `Cursor(newEpoch, 0)` — вершину «пустого» журнала. + * + * Используется при wipe / restore / миграции на новую схему (§6.3 спеки). + * Клиенты со старой эпохой получат [CursorExpiredException] с reason + * [CursorExpiredReason.WRONG_EPOCH] и обязаны сделать replaceState. + */ +interface EventLogWriter : EventLog { + /** Атомарно записать событие, вернуть его с назначенным `cursor`. */ + suspend fun append(event: DomainEvent): LoggedEvent + + /** Удалить события `cursor.number <= upTo.number` и сдвинуть minAvailableCursor. */ + suspend fun compact(upTo: Cursor) + + /** + * Атомарно стереть журнал и начать новую эпоху. Возвращает свежий + * `Cursor(newEpoch, 0)`. Materialized state тоже стирается (вызывающий + * должен заново его наполнить либо сделать replaceState из чтения). + */ + suspend fun beginNewEpoch(newEpoch: String): Cursor +} + +/** + * Клиентская сторона журнала (§4.3 спеки). Дополнительно к [EventLog]: + * применяет события сервера к локальной материализации, умеет делать + * `replaceState` при resync и управляет локальной очередью `PendingEvent`. + * + * Контракт [applyRemote]: + * - Событие уже закоммичено сервером — клиент только зеркалит. + * - Идемпотентность по [LoggedEvent.eventId]: повторный apply — no-op. + * - События применяются **в порядке `cursor`**. Если есть пропуск — + * [CursorMonotonicityViolationException] (см. §8.2 спеки). + * + * Контракт [replaceState]: + * - Полная замена локального состояния. Не merge (§2.5 спеки). + * - Курсор после replace = `snapshot.state.lastCursor`. + * - `PendingEvent`'ы **сохраняются** (см. §5.2 шаг 5 — они отправляются + * после resync). + * + * Контракт pending-API: + * - [enqueuePending] — добавить новое локальное событие в очередь. + * - [pendingEvents] — все события со статусом PENDING (для отправки на сервер). + * - [markPendingAsSent] / [markPendingAsFailed] — финализация. + */ +interface EventLogReplica : EventLog { + /** Применить событие от сервера к локальной материализации. */ + suspend fun applyRemote(event: LoggedEvent) + + /** + * Заменить локальное состояние снапшотом с сервера. Курсор сбрасывается + * на `snapshot.state.lastCursor`. + */ + suspend fun replaceState(snapshot: StateSnapshot) + + /** Все pending-события со статусом PENDING. */ + suspend fun pendingEvents(): List + + /** Добавить локальное событие в очередь (статус PENDING). */ + suspend fun enqueuePending(payload: DomainEvent, createdAt: Instant): PendingEvent + + /** Сервер принял событие под [cursor]. */ + suspend fun markPendingAsSent(localId: LocalId, cursor: Cursor) + + /** Сервер отверг событие. Клиент откатывает локальное изменение. */ + suspend fun markPendingAsFailed(localId: LocalId) + + /** Локальное состояние синхронизации (`cursor` + `lastSyncAt`). */ + suspend fun syncState(): SyncState +} + +/** + * Материализованное состояние (§4.1 спеки). + * + * Сервер пишет сюда через [applyOnServer] (атомарно с записью в журнал, + * см. [EventLogWriter.append]). Клиент пишет через [applyOnClient] + * (= [EventLogReplica.applyRemote]). Оба используют один и тот же [Reducer]. + */ +interface StateStore : AutoCloseable { + + /** Применить событие к состоянию и сохранить (серверная сторона). */ + suspend fun applyOnServer(event: LoggedEvent) + + /** Применить событие к состоянию и сохранить (клиентская сторона). */ + suspend fun applyOnClient(event: LoggedEvent) + + /** Полный снапшот состояния (= все строки, `lastCursor` = [ChatState.lastCursor]). */ + suspend fun readState(): StateSnapshot + + /** Курсор — максимум `lastCursor` по строкам в текущей эпохе (= точка отсечки снапшота). */ + suspend fun lastCursor(): Cursor + + override fun close() +} + +/** + * Редьюсер — чистая функция «событие + старое состояние → новое состояние». + * + * Один и тот же редьюсер работает и на сервере (применяется в транзакции + * `append`), и на клиенте (применяется через `applyRemote`). Это и есть + * «один и тот же код для клиента и сервера» из §0 спеки. + */ +fun interface Reducer { + fun reduce(state: S, event: LoggedEvent): S +} diff --git a/sync-core/src/commonMain/kotlin/pw/binom/agentik/sync/SyncEngine.kt b/sync-core/src/commonMain/kotlin/pw/binom/agentik/sync/SyncEngine.kt new file mode 100644 index 0000000..4540623 --- /dev/null +++ b/sync-core/src/commonMain/kotlin/pw/binom/agentik/sync/SyncEngine.kt @@ -0,0 +1,108 @@ +package pw.binom.agentik.sync + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow + +/** + * Алгоритм синхронизации (§5.2 спеки). Один объект живёт на клиенте, поверх + * [EventLogReplica] и [SyncTransport]. Два тела: + * + * - [sync]: однократный catchup + post pending. Возвращает [SyncReport] + * с числом apply'нутых событий и финальным `cursor`. + * - [liveFromCurrent]: долгоживущий Flow, подписывается на сервер и применяет + * каждое событие к локальной материализации через [EventLogReplica.applyRemote]. + * При разрыве/исключении Flow завершается — вызывающий решает, что делать + * (типичный паттерн: `retry { liveFromCurrent() }`). + * + * [sync] идемпотентен — повторный вызов ничего не сломает: догрузит только + * новые события, переотправит только pending. + */ +class SyncEngine( + private val replica: EventLogReplica, + private val transport: SyncTransport, +) { + + /** + * Однократный sync: replay-читать с `after = lastCursor`, разрешить + * протухший курсор через replaceState, отправить pending, дождаться + * подтверждений. + * + * Возвращает [SyncReport] для тестов и UI-метрик. + */ + suspend fun sync(pageSize: Int = 1024): SyncReport { + var applied = 0 + var resolvedGap = false + + // ── Шаги 1-4 спеки: catchup ──────────────────────────────────────── + while (true) { + val after = replica.syncState().cursor + val resp = transport.fetchUpdates(after = after, limit = pageSize) + when (resp) { + is UpdatesResponse.Ok -> { + if (resp.events.isEmpty() && !resp.hasMore) break + for (e in resp.events) { + replica.applyRemote(e) + applied++ + } + if (!resp.hasMore) break + } + is UpdatesResponse.CursorExpired -> { + // ── Шаг 3 спеки: full resync ─────────────────────────── + val state = transport.fetchState() + if (state !is StateResponse.Ok) error("StateResponse is always Ok in spec") + replica.replaceState(state.snapshot) + resolvedGap = true + break + } + } + } + + // ── Шаги 5-6 спеки: post pending ────────────────────────────────── + val pending = replica.pendingEvents() + var posted = 0 + var failed = 0 + if (pending.isNotEmpty()) { + val results = transport.postPending(pending) + for (r in results) { + when (r) { + is PostResult.Accepted -> { + replica.markPendingAsSent(r.localId, r.cursor) + posted++ + } + is PostResult.Rejected -> { + replica.markPendingAsFailed(r.localId) + failed++ + } + } + } + } + + return SyncReport( + appliedRemote = applied, + postedPending = posted, + failedPending = failed, + resolvedCursorGap = resolvedGap, + finalCursor = replica.syncState().cursor, + ) + } + + /** + * Live sync: долгоживущий Flow. Подписывается на [SyncTransport.subscribeLive] + * и применяет каждое событие через [EventLogReplica.applyRemote]. + */ + fun liveFromCurrent(after: Cursor? = null): Flow = flow { + val start = after ?: replica.syncState().cursor + transport.subscribeLive(start).collect { e -> + replica.applyRemote(e) + emit(e) + } + } +} + +data class SyncReport( + val appliedRemote: Int, + val postedPending: Int, + val failedPending: Int, + val resolvedCursorGap: Boolean, + val finalCursor: Cursor, +) diff --git a/sync-core/src/commonMain/kotlin/pw/binom/agentik/sync/Transport.kt b/sync-core/src/commonMain/kotlin/pw/binom/agentik/sync/Transport.kt new file mode 100644 index 0000000..e51c7a9 --- /dev/null +++ b/sync-core/src/commonMain/kotlin/pw/binom/agentik/sync/Transport.kt @@ -0,0 +1,119 @@ +package pw.binom.agentik.sync + +import kotlinx.coroutines.flow.Flow +import kotlinx.serialization.Serializable +import kotlin.time.Instant + +/** + * Транспорт синхронизации — сетевой слой между клиентом и сервером (§5.1 спеки). + * + * Спека описывает четыре endpoint'а: `GET /sync/state`, `GET /sync/updates`, + * `WS /sync/live`, `POST /sync/events`. Здесь они выражены как четыре метода + * интерфейса — конкретный транспорт (HTTP+WS, in-process, mock) реализует их + * по-своему. + * + * Контракт: + * - [fetchUpdates] может вернуть [UpdatesResponse.CursorExpired] с reason + * [CursorExpiredReason.WRONG_EPOCH] (epoch mismatch) или + * [CursorExpiredReason.TOO_OLD] (компакция переехала курсор). + * - [subscribeLive] — холодный Flow, отдаёт события `cursor > after` и + * завершается при разрыве (см. §5.2 шаг 6.2). + * - [postPending] — список результатов той же длины, что и вход; порядок + * сохраняется (клиент матчит по индексу / [LocalId]). + */ +interface SyncTransport : AutoCloseable { + + suspend fun fetchUpdates(after: Cursor, limit: Int): UpdatesResponse + + suspend fun fetchState(): StateResponse + + suspend fun postPending(events: List): List + + fun subscribeLive(after: Cursor): Flow + + override fun close() +} + +sealed interface UpdatesResponse { + /** Успешный replay — пачка событий + индикатор «есть ещё» + текущий курсор. */ + data class Ok( + val events: List, + val hasMore: Boolean, + val currentCursor: Cursor, + ) : UpdatesResponse + + /** + * Курсор протух. Клиент обязан сделать full resync через + * [SyncTransport.fetchState] и [EventLogReplica.replaceState]. + * + * - [reason] = [CursorExpiredReason.WRONG_EPOCH] → сервер сменил эпоху, + * клиент должен пересинхронизироваться с нуля. + * - [reason] = [CursorExpiredReason.TOO_OLD] → компакция удалила старые + * события, клиент должен пересинхронизироваться с нуля. + */ + data class CursorExpired( + val requested: Cursor, + val minAvailable: Cursor, + val current: Cursor, + val reason: CursorExpiredReason, + ) : UpdatesResponse +} + +sealed interface StateResponse { + /** + * Снапшот состояния + курсор, до которого материализация консистентна. + * Сервер гарантирует, что все строки в `snapshot.state.chats/messages` + * имеют `lastCursor.number <= upToCursor.number` в текущей эпохе, и + * `upToCursor` = `snapshot.state.lastCursor`. + */ + data class Ok( + val snapshot: StateSnapshot, + val upToCursor: Cursor, + val currentCursor: Cursor, + ) : StateResponse +} + +/** + * Результат `POST /sync/events` для одного pending-event'а. + * + * Идемпотентность по `eventId`: сервер может ответить [Accepted] уже + * назначенным ранее `cursor` (повторная отправка того же `eventId`). + */ +sealed interface PostResult { + data class Accepted(val localId: LocalId, val cursor: Cursor) : PostResult + data class Rejected(val localId: LocalId, val reason: String) : PostResult +} + +/** + * Запуск ассистента (§5.4 спеки). + * + * Клиент подписывается на поток событий и применяет их так же, как события + * от сервера. Локальный ассистент порождает `PendingEvent`, который потом + * уходит на сервер и подтверждается. + * + * Контракт: + * - `run(input)` запускает генерацию, возвращает холодный `Flow`. + * - Каждое событие несёт **стабильный** `eventId` (для идемпотентности). + * - `Flow` завершается, когда ассистент закончил генерацию. Ошибка внутри + * потока пробрасывается вызывающему. + */ +fun interface Assistant { + fun run(input: RunInput): Flow +} + +/** + * Параметры запуска ассистента (§4.4 спеки). + * + * - [chatId] — в каком чате генерируется ответ. + * - [userMessageId] — id только что созданного user-message'а + * (ассистент может сослаться на него в reasoning). + * - [reasoning] — что просит пользователь (например, текст сообщения). + * - [parentId] — какой message перегенерь (ветка), `null` для линейного ответа. + */ +@Serializable +data class RunInput( + val chatId: ChatId, + val userMessageId: MessageId, + val reasoning: String, + val parentId: MessageId? = null, +) diff --git a/sync-core/src/commonMain/kotlin/pw/binom/agentik/sync/ksqlite/EventCodec.kt b/sync-core/src/commonMain/kotlin/pw/binom/agentik/sync/ksqlite/EventCodec.kt new file mode 100644 index 0000000..35e530c --- /dev/null +++ b/sync-core/src/commonMain/kotlin/pw/binom/agentik/sync/ksqlite/EventCodec.kt @@ -0,0 +1,41 @@ +package pw.binom.agentik.sync.ksqlite + +import kotlinx.serialization.json.Json +import kotlinx.serialization.modules.SerializersModule +import kotlinx.serialization.modules.polymorphic +import kotlinx.serialization.modules.subclass +import pw.binom.agentik.sync.DomainEvent + +/** + * JSON-сериализация [DomainEvent] для хранения в SQLite. + * + * Все события полиморфно сериализуются с дискриминантом `type` (см. + * [DomainEvent] — `@SerialName(...)` на каждом подтипе). Полезная нагрузка — + * полный payload события (§2.4 спеки: «самодостаточны»). + */ +object EventCodec { + + val json: Json = Json { + ignoreUnknownKeys = true + encodeDefaults = true + classDiscriminator = "type" + serializersModule = SerializersModule { + polymorphic(DomainEvent::class) { + subclass(DomainEvent.ChatCreated::class) + subclass(DomainEvent.ChatRenamed::class) + subclass(DomainEvent.ChatDeleted::class) + subclass(DomainEvent.MessageAppended::class) + subclass(DomainEvent.MessageEdited::class) + subclass(DomainEvent.MessageDeleted::class) + subclass(DomainEvent.ToolCallRequested::class) + subclass(DomainEvent.ToolCallSucceeded::class) + subclass(DomainEvent.ToolCallFailed::class) + } + } + } + + fun encode(event: DomainEvent): String = json.encodeToString(DomainEvent.serializer(), event) + + fun decode(jsonText: String): DomainEvent = + json.decodeFromString(DomainEvent.serializer(), jsonText) +} \ No newline at end of file diff --git a/sync-core/src/commonMain/kotlin/pw/binom/agentik/sync/ksqlite/Schema.kt b/sync-core/src/commonMain/kotlin/pw/binom/agentik/sync/ksqlite/Schema.kt new file mode 100644 index 0000000..327d5d4 --- /dev/null +++ b/sync-core/src/commonMain/kotlin/pw/binom/agentik/sync/ksqlite/Schema.kt @@ -0,0 +1,169 @@ +package pw.binom.agentik.sync.ksqlite + +import pw.binom.db.ksqlite.SQLiteConnection + +/** + * Имена таблиц / колонок / индексов для ksqlite-бэкенда `:sync-core`. + * + * Реализует схему из §3.1 / §3.2 спеки с курсором `(epoch, number)` + * (§3.1.3). Семь таблиц: + * - `event_log` — append-only журнал (§3.1.1), `seq` монотонен + * внутри эпохи, `epoch` — safety-belt на случай гонок при wipe; + * - `chat_state` — материализация чатов (§3.1.2); + * - `message_state` — материализация сообщений (§3.1.2); + * - `compaction_state` — метаданные компакции + текущая эпоха (§3.1.3), + * одна строка; + * - `sync_state` — клиентский `cursor` (§3.2), одна строка; + * - `pending_event` — очередь pending-событий (§3.2); + * - `processed_event_id` — для идемпотентности по `eventId` (§8.1). + * + * В БД эпоха и номер курсора лежат в **отдельных колонках**: `epoch TEXT` + * (если есть в таблице), `seq INTEGER` для `last_seq` / `min_available_seq`. + * Курсор как объект [pw.binom.agentik.sync.Cursor] собирается при чтении. + * + * Все DDL/DML в этом модуле должны ссылаться на эти константы. + */ +object Schema { + + const val TABLE_EVENT_LOG = "sync_event_log" + const val TABLE_CHAT_STATE = "sync_chat_state" + const val TABLE_MESSAGE_STATE = "sync_message_state" + const val TABLE_COMPACTION_STATE = "sync_compaction_state" + const val TABLE_SYNC_STATE = "sync_sync_state" + const val TABLE_PENDING_EVENT = "sync_pending_event" + const val TABLE_PROCESSED_EVENT_ID = "sync_processed_event_id" + + const val COL_SEQ = "seq" + const val COL_EPOCH = "epoch" + const val COL_EVENT_ID = "event_id" + const val COL_PAYLOAD_JSON = "payload_json" + const val COL_CREATED_AT = "created_at" + const val COL_ORIGIN = "origin" + + const val COL_CHAT_ID = "chat_id" + const val COL_TITLE = "title" + const val COL_DELETED = "deleted" + const val COL_LAST_SEQ = "last_seq" + + const val COL_MESSAGE_ID = "message_id" + const val COL_ROLE = "role" + const val COL_CONTENT = "content" + const val COL_PARENT_ID = "parent_id" + + const val COL_LOCAL_ID = "local_id" + const val COL_TYPE = "type" + const val COL_STATUS = "status" + const val COL_SENT_CURSOR = "sent_cursor" + + const val COL_MIN_AVAILABLE_SEQ = "min_available_seq" + const val COL_LAST_COMPACTED_AT = "last_compacted_at" + + const val COL_SYNC_CURSOR = "cursor" + const val COL_LAST_SYNC_AT = "last_sync_at" + + /** Эпоха по умолчанию для новой БД. */ + const val DEFAULT_EPOCH = "v1" + + private val eventLogDdl = """ + CREATE TABLE IF NOT EXISTS $TABLE_EVENT_LOG ( + $COL_SEQ INTEGER PRIMARY KEY AUTOINCREMENT, + $COL_EPOCH TEXT NOT NULL, + $COL_EVENT_ID TEXT NOT NULL UNIQUE, + $COL_PAYLOAD_JSON TEXT NOT NULL, + $COL_CREATED_AT INTEGER NOT NULL, + $COL_ORIGIN TEXT + ); + """ + + private val chatStateDdl = """ + CREATE TABLE IF NOT EXISTS $TABLE_CHAT_STATE ( + $COL_CHAT_ID TEXT PRIMARY KEY, + $COL_TITLE TEXT NOT NULL, + $COL_CREATED_AT INTEGER NOT NULL, + $COL_DELETED INTEGER NOT NULL DEFAULT 0, + $COL_LAST_SEQ INTEGER NOT NULL + ); + """ + + private val messageStateDdl = """ + CREATE TABLE IF NOT EXISTS $TABLE_MESSAGE_STATE ( + $COL_MESSAGE_ID TEXT PRIMARY KEY, + $COL_CHAT_ID TEXT NOT NULL, + $COL_ROLE TEXT NOT NULL, + $COL_CONTENT TEXT NOT NULL, + $COL_PARENT_ID TEXT, + $COL_DELETED INTEGER NOT NULL DEFAULT 0, + $COL_LAST_SEQ INTEGER NOT NULL, + $COL_CREATED_AT INTEGER NOT NULL + ); + """ + + private val messageStateIdx = """ + CREATE INDEX IF NOT EXISTS idx_message_state_chat_seq + ON $TABLE_MESSAGE_STATE($COL_CHAT_ID, $COL_LAST_SEQ); + """ + + private val compactionStateDdl = """ + CREATE TABLE IF NOT EXISTS $TABLE_COMPACTION_STATE ( + id INTEGER PRIMARY KEY CHECK (id = 1), + $COL_EPOCH TEXT NOT NULL, + $COL_MIN_AVAILABLE_SEQ INTEGER NOT NULL DEFAULT 0, + $COL_LAST_COMPACTED_AT INTEGER + ); + """ + + private val syncStateDdl = """ + CREATE TABLE IF NOT EXISTS $TABLE_SYNC_STATE ( + id INTEGER PRIMARY KEY CHECK (id = 1), + $COL_SYNC_CURSOR TEXT NOT NULL, + $COL_LAST_SYNC_AT INTEGER + ); + """ + + private val pendingEventDdl = """ + CREATE TABLE IF NOT EXISTS $TABLE_PENDING_EVENT ( + $COL_LOCAL_ID TEXT PRIMARY KEY, + $COL_PAYLOAD_JSON TEXT NOT NULL, + $COL_CREATED_AT INTEGER NOT NULL, + $COL_STATUS TEXT NOT NULL, + $COL_SENT_CURSOR TEXT + ); + """ + + private val processedEventIdDdl = """ + CREATE TABLE IF NOT EXISTS $TABLE_PROCESSED_EVENT_ID ( + $COL_EVENT_ID TEXT PRIMARY KEY + ); + """ + + private val eventLogEpochIdx = """ + CREATE INDEX IF NOT EXISTS idx_event_log_epoch_seq + ON $TABLE_EVENT_LOG($COL_EPOCH, $COL_SEQ); + """ + + /** + * Создаёт все таблицы и индексы (идемпотентно). Миграция для schema v1 + * не нужна; future версии должны будут добавлять миграционный путь. + * + * Если таблица `compaction_state` создаётся впервые — синглтон-строка + * инициализируется эпохой [DEFAULT_EPOCH] и `min_available_seq = 0`. + * Аналогично `sync_state` инициализируется курсором `Cursor.ZERO`. + * + * Вызывающий отвечает за вызов под `BEGIN/COMMIT` или одиночно — обе формы + * безопасны (CREATE IF NOT EXISTS + INSERT OR IGNORE). + */ + fun migrate(conn: SQLiteConnection) { + conn.exec(eventLogDdl) + conn.exec(chatStateDdl) + conn.exec(messageStateDdl) + conn.exec(messageStateIdx) + conn.exec(compactionStateDdl) + conn.exec(syncStateDdl) + conn.exec(pendingEventDdl) + conn.exec(processedEventIdDdl) + conn.exec(eventLogEpochIdx) + // Гарантируем, что singleton-строки существуют. + conn.exec("INSERT OR IGNORE INTO $TABLE_COMPACTION_STATE (id, $COL_EPOCH, $COL_MIN_AVAILABLE_SEQ) VALUES (1, '$DEFAULT_EPOCH', 0)") + conn.exec("INSERT OR IGNORE INTO $TABLE_SYNC_STATE (id, $COL_SYNC_CURSOR) VALUES (1, '${pw.binom.agentik.sync.Cursor.ZERO.toRawString()}')") + } +} diff --git a/sync-core/src/commonMain/kotlin/pw/binom/agentik/sync/ksqlite/SqliteEventLog.kt b/sync-core/src/commonMain/kotlin/pw/binom/agentik/sync/ksqlite/SqliteEventLog.kt new file mode 100644 index 0000000..3005db5 --- /dev/null +++ b/sync-core/src/commonMain/kotlin/pw/binom/agentik/sync/ksqlite/SqliteEventLog.kt @@ -0,0 +1,385 @@ +package pw.binom.agentik.sync.ksqlite + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import pw.binom.agentik.sync.CompactionState +import pw.binom.agentik.sync.Cursor +import pw.binom.agentik.sync.CursorExpiredException +import pw.binom.agentik.sync.CursorExpiredReason +import pw.binom.agentik.sync.DomainEvent +import pw.binom.agentik.sync.EventLog +import pw.binom.agentik.sync.EventLogWriter +import pw.binom.agentik.sync.LoggedEvent +import pw.binom.db.ksqlite.SQLiteConnection +import pw.binom.db.ksqlite.SQLitePreparedStatement +import kotlin.time.Clock +import kotlin.time.Instant + +/** + * ksqlite-реализация [EventLog] + [EventLogWriter]. + * + * Контракт [append]: + * 1. BEGIN + * 2. SELECT min(seq) от processed_event_ids по eventId — если есть, вернуть + * уже закоммиченное событие (идемпотентность по §8.1 спеки). + * 3. INSERT INTO event_log — SQLite назначает `seq` как ROWID. + * 4. INSERT INTO processed_event_id. + * 5. COMMIT. + * 6. Опционально: нотификация через [appendFlow] для live-подписчиков. + * + * Контракт [compact]: + * 1. DELETE FROM event_log WHERE seq <= upTo.number. + * 2. UPDATE compaction_state SET min_available_seq = upTo.number + 1. + * 3. `processed_event_id` НЕ трогаем (см. §8.1 — это идемпотентность, она + * должна переживать компакцию). + * + * Контракт [beginNewEpoch]: + * 1. DELETE FROM event_log. + * 2. UPDATE compaction_state SET epoch = ?, min_available_seq = 0. + * 3. DELETE FROM sqlite_sequence WHERE name='sync_event_log'. + * 4. Очистить state-таблицы (вызывающий должен пересинхронизироваться). + * + * `seq INTEGER PRIMARY KEY AUTOINCREMENT` гарантирует монотонность внутри эпохи. + * После beginNewEpoch счётчик сбрасывается через `DELETE FROM sqlite_sequence`. + */ +class SqliteEventLog( + private val connection: SQLiteConnection, + private val ownsConnection: Boolean = false, +) : EventLogWriter { + + /** Broadcast append-канал — для live-подписчиков. */ + private val appendChannel: MutableSharedFlow = MutableSharedFlow( + replay = 0, + extraBufferCapacity = 64, + ) + + /** Публичный read-only view. */ + val appendFlow: SharedFlow get() = appendChannel + + private val appendListeners: MutableList Unit> = mutableListOf() + + fun onAppend(l: suspend (LoggedEvent) -> Unit) { + appendListeners.add(l) + } + + private suspend fun notifyAppend(l: LoggedEvent) { + for (f in appendListeners.toList()) f(l) + } + + private val mutex = Mutex() + + private val selectByEventIdStmt: SQLitePreparedStatement = connection.prepare( + """ + SELECT $COL_SEQ, $COL_EPOCH, $COL_EVENT_ID, $COL_PAYLOAD_JSON, $COL_CREATED_AT, $COL_ORIGIN + FROM ${Schema.TABLE_EVENT_LOG} + WHERE $COL_EVENT_ID = ? + """.trimIndent() + ) + + private val insertStmt: SQLitePreparedStatement = connection.prepare( + """ + INSERT INTO ${Schema.TABLE_EVENT_LOG} + ($COL_EPOCH, $COL_EVENT_ID, $COL_PAYLOAD_JSON, $COL_CREATED_AT, $COL_ORIGIN) + VALUES (?, ?, ?, ?, ?) + """.trimIndent() + ) + + private val insertProcessedStmt: SQLitePreparedStatement = connection.prepare( + """ + INSERT OR IGNORE INTO ${Schema.TABLE_PROCESSED_EVENT_ID} ($COL_EVENT_ID) VALUES (?) + """.trimIndent() + ) + + private val listStmt: SQLitePreparedStatement = connection.prepare( + """ + SELECT $COL_SEQ, $COL_EPOCH, $COL_EVENT_ID, $COL_PAYLOAD_JSON, $COL_CREATED_AT, $COL_ORIGIN + FROM ${Schema.TABLE_EVENT_LOG} + WHERE $COL_SEQ > ? + ORDER BY $COL_SEQ ASC + LIMIT ? + """.trimIndent() + ) + + private val maxSeqStmt: SQLitePreparedStatement = connection.prepare( + "SELECT MAX($COL_SEQ) FROM ${Schema.TABLE_EVENT_LOG}" + ) + + private val currentEpochStmt: SQLitePreparedStatement = connection.prepare( + "SELECT $COL_EPOCH, $COL_MIN_AVAILABLE_SEQ, $COL_LAST_COMPACTED_AT FROM ${Schema.TABLE_COMPACTION_STATE} WHERE id = 1" + ) + + private val compactStmt: SQLitePreparedStatement = connection.prepare( + "DELETE FROM ${Schema.TABLE_EVENT_LOG} WHERE $COL_SEQ <= ?" + ) + + private val updateMinAvailableStmt: SQLitePreparedStatement = connection.prepare( + """ + UPDATE ${Schema.TABLE_COMPACTION_STATE} + SET $COL_MIN_AVAILABLE_SEQ = ?, $COL_LAST_COMPACTED_AT = ? + WHERE id = 1 + """.trimIndent() + ) + + private val updateEpochStmt: SQLitePreparedStatement = connection.prepare( + """ + UPDATE ${Schema.TABLE_COMPACTION_STATE} + SET $COL_EPOCH = ?, $COL_MIN_AVAILABLE_SEQ = 0 + WHERE id = 1 + """.trimIndent() + ) + + private val wipeEventLogStmt: SQLitePreparedStatement = connection.prepare( + "DELETE FROM ${Schema.TABLE_EVENT_LOG}" + ) + + private val resetEventLogSeqStmt: SQLitePreparedStatement = connection.prepare( + "DELETE FROM sqlite_sequence WHERE name = ?" + ) + + private val lastInsertRowIdStmt: SQLitePreparedStatement = connection.prepare( + "SELECT last_insert_rowid()" + ) + + init { + // Schema.migrate должен быть вызван владельцем connection'а (см. SqliteSyncBundle). + } + + // ─── EventLog ─────────────────────────────────────────────────────────── + + override suspend fun readUpdates(after: Cursor, limit: Int): List = withContext(Dispatchers.Default) { + mutex.withLock { + val (currentEpoch, minAvailable) = currentEpochWithMinAvailable() + checkNotExpired(after, currentEpoch, minAvailable, currentCursorInternal(currentEpoch)) + listStmt.reset() + listStmt.clearBindings() + listStmt.bindLong(1, after.number.toLong()) + listStmt.bindLong(2, limit.toLong()) + val out = mutableListOf() + listStmt.executeQuery().use { rs -> + while (rs.next()) { + out.add(rs.toLoggedEvent()) + } + } + out + } + } + + override suspend fun lastCursor(): Cursor = withContext(Dispatchers.Default) { + mutex.withLock { + val (epoch, _) = currentEpochWithMinAvailable() + currentCursorInternal(epoch) + } + } + + override suspend fun compaction(): CompactionState = withContext(Dispatchers.Default) { + mutex.withLock { + currentEpochStmt.reset() + currentEpochStmt.clearBindings() + currentEpochStmt.executeQuery().use { rs -> + check(rs.next()) { "compaction_state must have one row" } + val epoch = rs.getText(0) ?: error("epoch is null") + val minSeq = rs.getLong(1) ?: 0L + val compactedAtMs = rs.getLong(2) + CompactionState( + currentEpoch = epoch, + minAvailableCursor = Cursor(epoch, minSeq.toULong()), + lastCompactedAt = compactedAtMs?.let { Instant.fromEpochMilliseconds(it) }, + ) + } + } + } + + // ─── EventLogWriter ──────────────────────────────────────────────────── + + override suspend fun append(event: DomainEvent): LoggedEvent = withContext(Dispatchers.Default) { + mutex.withLock { + // 1. Идемпотентность по eventId. + selectByEventIdStmt.reset() + selectByEventIdStmt.clearBindings() + selectByEventIdStmt.bindText(1, event.eventId) + val existing: LoggedEvent? = selectByEventIdStmt.executeQuery().use { rs -> + if (rs.next()) rs.toLoggedEvent() else null + } + if (existing != null) return@withContext existing + + // 2. Текущая эпоха из compaction_state. + val (epoch, _) = currentEpochWithMinAvailable() + val nowMs = Clock.System.now().toEpochMilliseconds() + + // 3. INSERT INTO event_log. + insertStmt.reset() + insertStmt.clearBindings() + insertStmt.bindText(1, epoch) + insertStmt.bindText(2, event.eventId) + insertStmt.bindText(3, EventCodec.encode(event)) + insertStmt.bindLong(4, nowMs) + insertStmt.bindNull(5) + insertStmt.executeUpdate() + val newSeq: Long = lastInsertRowIdStmt.executeQuery().use { rs -> + check(rs.next()) { "last_insert_rowid() must return a row" } + rs.getLong(0) ?: error("last_insert_rowid() is null") + } + + // 4. INSERT OR IGNORE INTO processed_event_id. + insertProcessedStmt.reset() + insertProcessedStmt.clearBindings() + insertProcessedStmt.bindText(1, event.eventId) + insertProcessedStmt.executeUpdate() + + val logged = LoggedEvent( + cursor = Cursor(epoch, newSeq.toULong()), + eventId = event.eventId, + payload = event, + createdAt = Instant.fromEpochMilliseconds(nowMs), + origin = null, + ) + appendChannel.tryEmit(logged) + notifyAppend(logged) + logged + } + } + + override suspend fun compact(upTo: Cursor): Unit = withContext(Dispatchers.Default) { + mutex.withLock { + val (currentEpoch, _) = currentEpochWithMinAvailable() + require(upTo.epoch == currentEpoch) { + "compact(upTo=$upTo) requires epoch == $currentEpoch" + } + compactStmt.reset() + compactStmt.clearBindings() + compactStmt.bindLong(1, upTo.number.toLong()) + compactStmt.executeUpdate() + val nowMs = Clock.System.now().toEpochMilliseconds() + updateMinAvailableStmt.reset() + updateMinAvailableStmt.clearBindings() + updateMinAvailableStmt.bindLong(1, (upTo.number + 1uL).toLong()) + updateMinAvailableStmt.bindLong(2, nowMs) + updateMinAvailableStmt.executeUpdate() + } + } + + override suspend fun beginNewEpoch(newEpoch: String): Cursor = withContext(Dispatchers.Default) { + require(newEpoch.isNotEmpty()) { "epoch must be non-empty" } + require('-' !in newEpoch) { "epoch must not contain '-': \"$newEpoch\"" } + mutex.withLock { + // 1. Стираем все события. + wipeEventLogStmt.reset() + wipeEventLogStmt.clearBindings() + wipeEventLogStmt.executeUpdate() + // 2. Сбрасываем autoincrement для event_log. + resetEventLogSeqStmt.reset() + resetEventLogSeqStmt.clearBindings() + resetEventLogSeqStmt.bindText(1, Schema.TABLE_EVENT_LOG) + resetEventLogSeqStmt.executeUpdate() + // 3. Меняем текущую эпоху и обнуляем min_available_seq. + updateEpochStmt.reset() + updateEpochStmt.clearBindings() + updateEpochStmt.bindText(1, newEpoch) + updateEpochStmt.executeUpdate() + Cursor(newEpoch, 0uL) + } + } + + // ─── helpers ──────────────────────────────────────────────────────────── + + private fun currentEpochWithMinAvailable(): Pair { + currentEpochStmt.reset() + currentEpochStmt.clearBindings() + currentEpochStmt.executeQuery().use { rs -> + check(rs.next()) { "compaction_state must have one row" } + val epoch = rs.getText(0) ?: error("epoch is null") + val minSeq = rs.getLong(1) ?: 0L + return epoch to minSeq.toULong() + } + } + + private fun currentCursorInternal(epoch: String): Cursor { + maxSeqStmt.reset() + maxSeqStmt.clearBindings() + maxSeqStmt.executeQuery().use { rs -> + check(rs.next()) { "MAX must return one row" } + val max = rs.getLong(0) + return if (max == null) Cursor(epoch, 0uL) else Cursor(epoch, max.toULong()) + } + } + + private fun checkNotExpired( + requested: Cursor, + currentEpoch: String, + minAvailable: ULong, + current: Cursor, + ) { + if (requested.epoch != currentEpoch) { + throw CursorExpiredException( + requested = requested, + currentEpoch = currentEpoch, + minAvailable = Cursor(currentEpoch, minAvailable), + current = current, + reason = CursorExpiredReason.WRONG_EPOCH, + ) + } + if (requested.number < minAvailable) { + throw CursorExpiredException( + requested = requested, + currentEpoch = currentEpoch, + minAvailable = Cursor(currentEpoch, minAvailable), + current = current, + reason = CursorExpiredReason.TOO_OLD, + ) + } + } + + private fun pw.binom.db.ksqlite.SQLiteResultSet.toLoggedEvent(): LoggedEvent { + val seq = getLong(0) ?: error("seq is null") + val epoch = getText(1) ?: error("epoch is null") + return LoggedEvent( + cursor = Cursor(epoch, seq.toULong()), + eventId = getText(2) ?: error("event_id is null"), + payload = EventCodec.decode(getText(3) ?: error("payload_json is null")), + createdAt = Instant.fromEpochMilliseconds(getLong(4) ?: error("created_at is null")), + origin = getText(5), + ) + } + + fun closeStmts() { + selectByEventIdStmt.close() + insertStmt.close() + insertProcessedStmt.close() + listStmt.close() + maxSeqStmt.close() + currentEpochStmt.close() + compactStmt.close() + updateMinAvailableStmt.close() + updateEpochStmt.close() + wipeEventLogStmt.close() + resetEventLogSeqStmt.close() + lastInsertRowIdStmt.close() + if (ownsConnection) { + connection.close() + } + } + + override fun close() = closeStmts() + + companion object { + // ── Local column-name aliases ─────────────────────────────────────── + private const val COL_SEQ = Schema.COL_SEQ + private const val COL_EPOCH = Schema.COL_EPOCH + private const val COL_EVENT_ID = Schema.COL_EVENT_ID + private const val COL_PAYLOAD_JSON = Schema.COL_PAYLOAD_JSON + private const val COL_CREATED_AT = Schema.COL_CREATED_AT + private const val COL_ORIGIN = Schema.COL_ORIGIN + private const val COL_MIN_AVAILABLE_SEQ = Schema.COL_MIN_AVAILABLE_SEQ + private const val COL_LAST_COMPACTED_AT = Schema.COL_LAST_COMPACTED_AT + + fun memory(name: String? = null): SqliteEventLog = + SqliteEventLog( + connection = SQLiteConnection.memory(name), + ownsConnection = true, + ) + } +} \ No newline at end of file diff --git a/sync-core/src/commonMain/kotlin/pw/binom/agentik/sync/ksqlite/SqliteEventLogReplica.kt b/sync-core/src/commonMain/kotlin/pw/binom/agentik/sync/ksqlite/SqliteEventLogReplica.kt new file mode 100644 index 0000000..aa2b8ea --- /dev/null +++ b/sync-core/src/commonMain/kotlin/pw/binom/agentik/sync/ksqlite/SqliteEventLogReplica.kt @@ -0,0 +1,319 @@ +package pw.binom.agentik.sync.ksqlite + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import pw.binom.agentik.sync.Cursor +import pw.binom.agentik.sync.DomainEvent +import pw.binom.agentik.sync.EventLogReplica +import pw.binom.agentik.sync.LocalId +import pw.binom.agentik.sync.LoggedEvent +import pw.binom.agentik.sync.PendingEvent +import pw.binom.agentik.sync.PendingStatus +import pw.binom.agentik.sync.SeqMonotonicityViolationException +import pw.binom.agentik.sync.StateSnapshot +import pw.binom.agentik.sync.SyncState +import pw.binom.db.ksqlite.SQLiteConnection +import pw.binom.db.ksqlite.SQLitePreparedStatement +import kotlin.time.Clock +import kotlin.time.Instant + +/** + * ksqlite-реализация [EventLogReplica] (клиентская сторона). + * + * Локальный event-log на клиенте **не нужен** — это серверная абстракция. + * Клиент держит: + * - материализацию в [SqliteStateStore]; + * - sync_state (`cursor`, `last_sync_at`) в singleton-row; + * - pending_event (очередь); + * - processed_event_id (идемпотентность applyRemote). + * + * `readUpdates` на клиенте возвращает **пустой список** — replay-логики на + * стороне клиента нет, она вся делается через [transport.fetchUpdates]. + * [lastCursor] читается из sync_state. + * + * [applyRemote] оптимистично применяет событие к state через reducer и + * записывает в processed_event_id (идемпотентность). Если `event.cursor` + * вне текущей эпохи — клиент должен сделать replaceState (см. SyncEngine). + * + * [enqueuePending] применяет событие оптимистично (как будто оно уже в + * журнале) и кладёт в pending_event с status=PENDING. Назначаемый `cursor` + * для оптимистичного применения — это `lastCursor().next()`; реальный + * серверный cursor перезапишется при [markPendingAsSent]. + */ +class SqliteEventLogReplica( + private val connection: SQLiteConnection, + private val stateStore: SqliteStateStore, +) : EventLogReplica { + + private val mutex = Mutex() + + private val updateSyncStateStmt: SQLitePreparedStatement = connection.prepare( + """ + UPDATE ${Schema.TABLE_SYNC_STATE} + SET $COL_SYNC_CURSOR = ?, $COL_LAST_SYNC_AT = ? + WHERE id = 1 + """.trimIndent() + ) + + private val selectSyncStateStmt: SQLitePreparedStatement = connection.prepare( + "SELECT $COL_SYNC_CURSOR, $COL_LAST_SYNC_AT FROM ${Schema.TABLE_SYNC_STATE} WHERE id = 1" + ) + + private val insertProcessedStmt: SQLitePreparedStatement = connection.prepare( + "INSERT OR IGNORE INTO ${Schema.TABLE_PROCESSED_EVENT_ID} ($COL_EVENT_ID) VALUES (?)" + ) + + private val checkEventIdStmt: SQLitePreparedStatement = connection.prepare( + "SELECT 1 FROM ${Schema.TABLE_PROCESSED_EVENT_ID} WHERE $COL_EVENT_ID = ? LIMIT 1" + ) + + private val selectPendingStmt: SQLitePreparedStatement = connection.prepare( + """ + SELECT $COL_LOCAL_ID, $COL_PAYLOAD_JSON, $COL_CREATED_AT, $COL_STATUS, $COL_SENT_CURSOR + FROM ${Schema.TABLE_PENDING_EVENT} + WHERE $COL_STATUS = ? + ORDER BY $COL_CREATED_AT ASC + """.trimIndent() + ) + + private val selectAllPendingStmt: SQLitePreparedStatement = connection.prepare( + "SELECT $COL_LOCAL_ID, $COL_PAYLOAD_JSON, $COL_CREATED_AT, $COL_STATUS, $COL_SENT_CURSOR FROM ${Schema.TABLE_PENDING_EVENT}" + ) + + private val insertPendingStmt: SQLitePreparedStatement = connection.prepare( + """ + INSERT INTO ${Schema.TABLE_PENDING_EVENT} + ($COL_LOCAL_ID, $COL_PAYLOAD_JSON, $COL_CREATED_AT, $COL_STATUS) + VALUES (?, ?, ?, ?) + """.trimIndent() + ) + + private val updatePendingStatusStmt: SQLitePreparedStatement = connection.prepare( + """ + UPDATE ${Schema.TABLE_PENDING_EVENT} + SET $COL_STATUS = ?, $COL_SENT_CURSOR = ? + WHERE $COL_LOCAL_ID = ? + """.trimIndent() + ) + + // ─── EventLog ─────────────────────────────────────────────────────────── + + override suspend fun readUpdates(after: Cursor, limit: Int): List = emptyList() + + override suspend fun lastCursor(): Cursor = withContext(Dispatchers.Default) { + mutex.withLock { readSyncStateLocked().cursor } + } + + override suspend fun compaction() = stateStore.compaction() + + // ─── EventLogReplica ─────────────────────────────────────────────────── + + override suspend fun applyRemote(event: LoggedEvent): Unit = withContext(Dispatchers.Default) { + mutex.withLock { + // 1. Идемпотентность по eventId. + if (isEventIdProcessed(event.eventId)) return@withContext + // 2. Монотонность в текущей эпохе. + val currentCursor = readSyncStateCursorLocked() + if (event.cursor.epoch != currentCursor.epoch) { + throw SeqMonotonicityViolationException( + previous = currentCursor.number.toLong(), + attempted = event.cursor.number.toLong(), + ) + } + if (event.cursor.number <= currentCursor.number) { + throw SeqMonotonicityViolationException( + previous = currentCursor.number.toLong(), + attempted = event.cursor.number.toLong(), + ) + } + // 3. Помечаем как processed. + insertProcessedStmt.reset() + insertProcessedStmt.clearBindings() + insertProcessedStmt.bindText(1, event.eventId) + insertProcessedStmt.executeUpdate() + // 4. Применяем через state store. + stateStore.applyOnClient(event) + // 5. Обновляем sync_state.cursor. + updateSyncStateStmt.reset() + updateSyncStateStmt.clearBindings() + updateSyncStateStmt.bindText(1, event.cursor.toRawString()) + updateSyncStateStmt.bindLong(2, Clock.System.now().toEpochMilliseconds()) + updateSyncStateStmt.executeUpdate() + } + } + + /** Проверяет, был ли eventId уже записан в processed_event_id. */ + private fun isEventIdProcessed(eventId: String): Boolean { + checkEventIdStmt.reset() + checkEventIdStmt.clearBindings() + checkEventIdStmt.bindText(1, eventId) + checkEventIdStmt.executeQuery().use { rs -> + return rs.next() + } + } + + override suspend fun replaceState(snapshot: StateSnapshot): Unit = withContext(Dispatchers.Default) { + stateStore.replaceState(snapshot) + mutex.withLock { + updateSyncStateStmt.reset() + updateSyncStateStmt.clearBindings() + updateSyncStateStmt.bindText(1, snapshot.state.lastCursor.toRawString()) + updateSyncStateStmt.bindLong(2, Clock.System.now().toEpochMilliseconds()) + updateSyncStateStmt.executeUpdate() + } + } + + override suspend fun pendingEvents(): List = withContext(Dispatchers.Default) { + selectPendingStmt.reset() + selectPendingStmt.clearBindings() + selectPendingStmt.bindText(1, PendingStatus.PENDING.name) + val out = mutableListOf() + selectPendingStmt.executeQuery().use { rs -> + while (rs.next()) out.add(rs.toPending()) + } + out + } + + /** Все pending-события (для тестов). */ + suspend fun allPending(): List = withContext(Dispatchers.Default) { + selectAllPendingStmt.reset() + selectAllPendingStmt.clearBindings() + val out = mutableListOf() + selectAllPendingStmt.executeQuery().use { rs -> + while (rs.next()) out.add(rs.toPending()) + } + out + } + + override suspend fun enqueuePending(payload: DomainEvent, createdAt: Instant): PendingEvent = withContext(Dispatchers.Default) { + mutex.withLock { + val pe = PendingEvent( + localId = LocalId(payload.eventId), + payload = payload, + createdAt = createdAt, + status = PendingStatus.PENDING, + ) + insertPendingStmt.reset() + insertPendingStmt.clearBindings() + insertPendingStmt.bindText(1, pe.localId.raw) + insertPendingStmt.bindText(2, EventCodec.encode(payload)) + insertPendingStmt.bindLong(3, createdAt.toEpochMilliseconds()) + insertPendingStmt.bindText(4, PendingStatus.PENDING.name) + insertPendingStmt.executeUpdate() + // Оптимистичное применение к материализации с фиктивным cursor = lastCursor().next(). + val fakeCursor = stateStore.lastCursor().next() + val fakeLogged = LoggedEvent( + cursor = fakeCursor, + eventId = payload.eventId, + payload = payload, + createdAt = createdAt, + ) + stateStore.applyOnClient(fakeLogged) + pe + } + } + + override suspend fun markPendingAsSent(localId: LocalId, cursor: Cursor): Unit = withContext(Dispatchers.Default) { + updatePendingStatusStmt.reset() + updatePendingStatusStmt.clearBindings() + updatePendingStatusStmt.bindText(1, PendingStatus.SENT.name) + updatePendingStatusStmt.bindText(2, cursor.toRawString()) + updatePendingStatusStmt.bindText(3, localId.raw) + updatePendingStatusStmt.executeUpdate() + mutex.withLock { + updateSyncStateStmt.reset() + updateSyncStateStmt.clearBindings() + updateSyncStateStmt.bindText(1, cursor.toRawString()) + updateSyncStateStmt.bindLong(2, Clock.System.now().toEpochMilliseconds()) + updateSyncStateStmt.executeUpdate() + } + } + + override suspend fun markPendingAsFailed(localId: LocalId): Unit = withContext(Dispatchers.Default) { + updatePendingStatusStmt.reset() + updatePendingStatusStmt.clearBindings() + updatePendingStatusStmt.bindText(1, PendingStatus.FAILED.name) + updatePendingStatusStmt.bindNull(2) + updatePendingStatusStmt.bindText(3, localId.raw) + updatePendingStatusStmt.executeUpdate() + } + + override suspend fun syncState(): SyncState = withContext(Dispatchers.Default) { + mutex.withLock { readSyncStateLocked() } + } + + // ─── helpers ──────────────────────────────────────────────────────────── + + private fun readSyncStateLocked(): SyncState { + selectSyncStateStmt.reset() + selectSyncStateStmt.clearBindings() + selectSyncStateStmt.executeQuery().use { rs -> + check(rs.next()) + val cursor = Cursor.fromString(rs.getText(0) ?: error("cursor is null")) + val lastSyncAtMs = rs.getLong(1) + return SyncState( + cursor = cursor, + lastSyncAt = lastSyncAtMs?.let { Instant.fromEpochMilliseconds(it) }, + ) + } + } + + private fun readSyncStateCursorLocked(): Cursor = readSyncStateLocked().cursor + + private fun pw.binom.db.ksqlite.SQLiteResultSet.toPending(): PendingEvent { + val sent = getText(4) + return PendingEvent( + localId = LocalId(getText(0) ?: error("local_id null")), + payload = EventCodec.decode(getText(1) ?: error("payload null")), + createdAt = Instant.fromEpochMilliseconds(getLong(2) ?: 0L), + status = PendingStatus.valueOf(getText(3) ?: "PENDING"), + sentCursor = sent?.let { Cursor.fromString(it) }, + ) + } + + fun closeStmts() { + updateSyncStateStmt.close() + selectSyncStateStmt.close() + insertProcessedStmt.close() + checkEventIdStmt.close() + selectPendingStmt.close() + selectAllPendingStmt.close() + insertPendingStmt.close() + updatePendingStatusStmt.close() + } + + override fun close() = closeStmts() + + /** + * Только для тестов: полностью wipe'нуть локальную материализацию и + * перевести клиента в новую эпоху. В нормальной работе клиент узнаёт о + * новой эпохе из `CursorExpired.WRONG_EPOCH` через `SyncEngine.sync` → + * replaceState. + */ + fun beginNewEpoch(newEpoch: String): Cursor { + connection.exec("DELETE FROM ${Schema.TABLE_CHAT_STATE}") + connection.exec("DELETE FROM ${Schema.TABLE_MESSAGE_STATE}") + connection.exec("DELETE FROM ${Schema.TABLE_PROCESSED_EVENT_ID}") + connection.exec("DELETE FROM ${Schema.TABLE_PENDING_EVENT}") + connection.exec("DELETE FROM ${Schema.TABLE_SYNC_STATE}") + connection.exec( + "UPDATE ${Schema.TABLE_COMPACTION_STATE} " + + "SET ${Schema.COL_EPOCH} = '$newEpoch', " + + "${Schema.COL_MIN_AVAILABLE_SEQ} = 0" + ) + return Cursor(newEpoch, 0uL) + } + + companion object { + private const val COL_LOCAL_ID = Schema.COL_LOCAL_ID + private const val COL_PAYLOAD_JSON = Schema.COL_PAYLOAD_JSON + private const val COL_CREATED_AT = Schema.COL_CREATED_AT + private const val COL_STATUS = Schema.COL_STATUS + private const val COL_SENT_CURSOR = Schema.COL_SENT_CURSOR + private const val COL_EVENT_ID = Schema.COL_EVENT_ID + private const val COL_SYNC_CURSOR = Schema.COL_SYNC_CURSOR + private const val COL_LAST_SYNC_AT = Schema.COL_LAST_SYNC_AT + } +} \ No newline at end of file diff --git a/sync-core/src/commonMain/kotlin/pw/binom/agentik/sync/ksqlite/SqliteStateStore.kt b/sync-core/src/commonMain/kotlin/pw/binom/agentik/sync/ksqlite/SqliteStateStore.kt new file mode 100644 index 0000000..3493521 --- /dev/null +++ b/sync-core/src/commonMain/kotlin/pw/binom/agentik/sync/ksqlite/SqliteStateStore.kt @@ -0,0 +1,339 @@ +package pw.binom.agentik.sync.ksqlite + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import pw.binom.agentik.sync.Chat +import pw.binom.agentik.sync.ChatId +import pw.binom.agentik.sync.ChatState +import pw.binom.agentik.sync.CompactionState +import pw.binom.agentik.sync.Cursor +import pw.binom.agentik.sync.DefaultChatReducer +import pw.binom.agentik.sync.LoggedEvent +import pw.binom.agentik.sync.Message +import pw.binom.agentik.sync.MessageId +import pw.binom.agentik.sync.MessageRole +import pw.binom.agentik.sync.Reducer +import pw.binom.agentik.sync.StateSnapshot +import pw.binom.agentik.sync.StateStore +import pw.binom.db.ksqlite.SQLiteConnection +import pw.binom.db.ksqlite.SQLitePreparedStatement +import kotlin.time.Instant + +/** + * ksqlite-реализация [StateStore]. Материализация держится в таблицах + * `chat_state` + `message_state`. На каждом `applyEvent` редьюсер считает + * новое значение строки, и мы пишем его в таблицу. + * + * В БД эпоха хранится в [Schema.TABLE_COMPACTION_STATE] (singleton), а + * строки `chat_state` / `message_state` несут только номер (`last_seq INTEGER`) + * в текущей эпохе — это нормально, потому что [replaceState] стирает все + * строки при смене эпохи. При чтении DTO ([Chat] / [Message]) номер + * дополняется текущей эпохой из compaction_state. + */ +class SqliteStateStore( + private val connection: SQLiteConnection, + private val reducer: Reducer = DefaultChatReducer, +) : StateStore { + + /** Теневое in-memory состояние — обновляется в одном темпе с SQL. */ + private var shadow: ChatState = ChatState() + + /** Текущая эпоха — кэш для [lastCursor] / [compaction]. */ + private var currentEpoch: String = Schema.DEFAULT_EPOCH + + private val mutex = Mutex() + + private val upsertChatStmt: SQLitePreparedStatement = connection.prepare( + """ + INSERT INTO ${Schema.TABLE_CHAT_STATE} + ($COL_CHAT_ID, $COL_TITLE, $COL_CREATED_AT, $COL_DELETED, $COL_LAST_SEQ) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT($COL_CHAT_ID) DO UPDATE SET + $COL_TITLE = excluded.$COL_TITLE, + $COL_DELETED = excluded.$COL_DELETED, + $COL_LAST_SEQ = excluded.$COL_LAST_SEQ + """.trimIndent() + ) + + private val deleteChatStmt: SQLitePreparedStatement = connection.prepare( + "DELETE FROM ${Schema.TABLE_CHAT_STATE} WHERE $COL_CHAT_ID = ?" + ) + + private val upsertMessageStmt: SQLitePreparedStatement = connection.prepare( + """ + INSERT INTO ${Schema.TABLE_MESSAGE_STATE} + ($COL_MESSAGE_ID, $COL_CHAT_ID, $COL_ROLE, $COL_CONTENT, $COL_PARENT_ID, + $COL_DELETED, $COL_LAST_SEQ, $COL_CREATED_AT) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT($COL_MESSAGE_ID) DO UPDATE SET + $COL_CONTENT = excluded.$COL_CONTENT, + $COL_PARENT_ID = excluded.$COL_PARENT_ID, + $COL_DELETED = excluded.$COL_DELETED, + $COL_LAST_SEQ = excluded.$COL_LAST_SEQ + """.trimIndent() + ) + + private val deleteMessageStmt: SQLitePreparedStatement = connection.prepare( + "DELETE FROM ${Schema.TABLE_MESSAGE_STATE} WHERE $COL_MESSAGE_ID = ?" + ) + + private val selectChatsStmt: SQLitePreparedStatement = connection.prepare( + "SELECT $COL_CHAT_ID, $COL_TITLE, $COL_CREATED_AT, $COL_DELETED, $COL_LAST_SEQ FROM ${Schema.TABLE_CHAT_STATE}" + ) + + private val selectMessagesStmt: SQLitePreparedStatement = connection.prepare( + """ + SELECT $COL_MESSAGE_ID, $COL_CHAT_ID, $COL_ROLE, $COL_CONTENT, $COL_PARENT_ID, + $COL_DELETED, $COL_LAST_SEQ, $COL_CREATED_AT + FROM ${Schema.TABLE_MESSAGE_STATE} + ORDER BY $COL_CHAT_ID, $COL_LAST_SEQ + """.trimIndent() + ) + + private val truncateChatsStmt: SQLitePreparedStatement = connection.prepare( + "DELETE FROM ${Schema.TABLE_CHAT_STATE}" + ) + private val truncateMessagesStmt: SQLitePreparedStatement = connection.prepare( + "DELETE FROM ${Schema.TABLE_MESSAGE_STATE}" + ) + + private val selectCurrentEpochStmt: SQLitePreparedStatement = connection.prepare( + "SELECT $COL_EPOCH FROM ${Schema.TABLE_COMPACTION_STATE} WHERE id = 1" + ) + + init { + // Подгружаем текущее состояние в shadow. + runBlocking { reloadShadow() } + } + + // ─── StateStore ──────────────────────────────────────────────────────── + + override suspend fun applyOnServer(event: LoggedEvent) = withContext(Dispatchers.Default) { + mutex.withLock { + val next = reducer.reduce(shadow, event) + writeDiff(shadow, next, event.cursor) + shadow = next + } + } + + override suspend fun applyOnClient(event: LoggedEvent) = applyOnServer(event) + + override suspend fun readState(): StateSnapshot = withContext(Dispatchers.Default) { + mutex.withLock { StateSnapshot(state = shadow) } + } + + override suspend fun lastCursor(): Cursor = withContext(Dispatchers.Default) { + mutex.withLock { shadow.lastCursor } + } + + /** Доступ к compaction state — нужен [SqliteEventLogReplica.compaction]. */ + suspend fun compaction(): CompactionState = withContext(Dispatchers.Default) { + mutex.withLock { readCompactionLocked() } + } + + /** Полная замена состояния снапшотом (см. [EventLogReplica.replaceState]). */ + suspend fun replaceState(snapshot: StateSnapshot) = withContext(Dispatchers.Default) { + mutex.withLock { + // Обновим кэш эпохи — снапшот может прийти из новой эпохи. + val epochFromState = snapshot.state.lastCursor.epoch + currentEpoch = epochFromState + connection.exec("BEGIN") + try { + truncateChatsStmt.reset() + truncateChatsStmt.clearBindings() + truncateChatsStmt.executeUpdate() + truncateMessagesStmt.reset() + truncateMessagesStmt.clearBindings() + truncateMessagesStmt.executeUpdate() + for (chat in snapshot.state.chats.values) { + upsertChatStmt.reset() + upsertChatStmt.clearBindings() + upsertChatStmt.bindText(1, chat.id.raw) + upsertChatStmt.bindText(2, chat.title) + upsertChatStmt.bindLong(3, chat.createdAt.toEpochMilliseconds()) + upsertChatStmt.bindLong(4, if (chat.deleted) 1L else 0L) + upsertChatStmt.bindLong(5, chat.lastCursor.number.toLong()) + upsertChatStmt.executeUpdate() + } + for ((_, msgs) in snapshot.state.messages) { + for (m in msgs) { + upsertMessageStmt.reset() + upsertMessageStmt.clearBindings() + upsertMessageStmt.bindText(1, m.id.raw) + upsertMessageStmt.bindText(2, m.chatId.raw) + upsertMessageStmt.bindText(3, m.role.name) + upsertMessageStmt.bindText(4, m.content) + if (m.parentId == null) upsertMessageStmt.bindNull(5) + else upsertMessageStmt.bindText(5, m.parentId.raw) + upsertMessageStmt.bindLong(6, if (m.deleted) 1L else 0L) + upsertMessageStmt.bindLong(7, m.lastCursor.number.toLong()) + upsertMessageStmt.bindLong(8, m.createdAt.toEpochMilliseconds()) + upsertMessageStmt.executeUpdate() + } + } + connection.exec("COMMIT") + } catch (t: Throwable) { + runCatching { connection.exec("ROLLBACK") } + throw t + } + shadow = snapshot.state + } + } + + // ─── helpers ──────────────────────────────────────────────────────────── + + private fun readCurrentEpochLocked(): String { + selectCurrentEpochStmt.reset() + selectCurrentEpochStmt.clearBindings() + selectCurrentEpochStmt.executeQuery().use { rs -> + check(rs.next()) + return rs.getText(0) ?: Schema.DEFAULT_EPOCH + } + } + + private fun readCompactionLocked(): CompactionState { + val stmt = connection.prepare("SELECT epoch, min_available_seq, last_compacted_at FROM ${Schema.TABLE_COMPACTION_STATE} WHERE id = 1") + try { + stmt.executeQuery().use { rs -> + check(rs.next()) + val epoch = rs.getText(0) ?: Schema.DEFAULT_EPOCH + val min = rs.getLong(1) ?: 0L + val ms = rs.getLong(2) + return CompactionState( + currentEpoch = epoch, + minAvailableCursor = Cursor(epoch, min.toULong()), + lastCompactedAt = ms?.let { Instant.fromEpochMilliseconds(it) }, + ) + } + } finally { + stmt.close() + } + } + + private suspend fun reloadShadow() = withContext(Dispatchers.Default) { + mutex.withLock { + currentEpoch = readCurrentEpochLocked() + val chats = LinkedHashMap() + selectChatsStmt.reset() + selectChatsStmt.clearBindings() + selectChatsStmt.executeQuery().use { rs -> + while (rs.next()) { + val lastSeq = rs.getLong(4) ?: 0L + val chat = Chat( + id = ChatId(rs.getText(0) ?: error("chat_id null")), + title = rs.getText(1) ?: "", + createdAt = Instant.fromEpochMilliseconds(rs.getLong(2) ?: 0L), + deleted = (rs.getLong(3) ?: 0L) != 0L, + lastCursor = Cursor(currentEpoch, lastSeq.toULong()), + ) + chats[chat.id] = chat + } + } + val messages = LinkedHashMap>() + selectMessagesStmt.reset() + selectMessagesStmt.clearBindings() + selectMessagesStmt.executeQuery().use { rs -> + while (rs.next()) { + val lastSeq = rs.getLong(6) ?: 0L + val m = Message( + id = MessageId(rs.getText(0) ?: error("message_id null")), + chatId = ChatId(rs.getText(1) ?: error("chat_id null")), + role = MessageRole.valueOf(rs.getText(2) ?: "USER"), + content = rs.getText(3) ?: "", + parentId = rs.getText(4)?.let { MessageId(it) }, + deleted = (rs.getLong(5) ?: 0L) != 0L, + lastCursor = Cursor(currentEpoch, lastSeq.toULong()), + createdAt = Instant.fromEpochMilliseconds(rs.getLong(7) ?: 0L), + ) + messages.getOrPut(m.chatId) { mutableListOf() }.add(m) + } + } + shadow = ChatState(chats = chats, messages = messages) + } + } + + private fun writeDiff(old: ChatState, new: ChatState, eventCursor: Cursor) { + // Chats: added / updated / removed. + for ((id, nc) in new.chats) { + val oc = old.chats[id] + if (oc == null || oc.title != nc.title || oc.deleted != nc.deleted || oc.lastCursor != nc.lastCursor) { + upsertChatStmt.reset() + upsertChatStmt.clearBindings() + upsertChatStmt.bindText(1, id.raw) + upsertChatStmt.bindText(2, nc.title) + upsertChatStmt.bindLong(3, nc.createdAt.toEpochMilliseconds()) + upsertChatStmt.bindLong(4, if (nc.deleted) 1L else 0L) + upsertChatStmt.bindLong(5, nc.lastCursor.number.toLong()) + upsertChatStmt.executeUpdate() + } + } + for ((id, _) in old.chats) { + if (id !in new.chats) { + deleteChatStmt.reset() + deleteChatStmt.clearBindings() + deleteChatStmt.bindText(1, id.raw) + deleteChatStmt.executeUpdate() + } + } + // Messages: то же самое. + for ((chatId, msgs) in new.messages) { + val oldMsgsById = old.messages[chatId]?.associateBy { it.id } ?: emptyMap() + for (m in msgs) { + val om = oldMsgsById[m.id] + if (om == null || om.content != m.content || om.deleted != m.deleted || + om.lastCursor != m.lastCursor || om.parentId != m.parentId + ) { + upsertMessageStmt.reset() + upsertMessageStmt.clearBindings() + upsertMessageStmt.bindText(1, m.id.raw) + upsertMessageStmt.bindText(2, m.chatId.raw) + upsertMessageStmt.bindText(3, m.role.name) + upsertMessageStmt.bindText(4, m.content) + if (m.parentId == null) upsertMessageStmt.bindNull(5) + else upsertMessageStmt.bindText(5, m.parentId.raw) + upsertMessageStmt.bindLong(6, if (m.deleted) 1L else 0L) + upsertMessageStmt.bindLong(7, m.lastCursor.number.toLong()) + upsertMessageStmt.bindLong(8, m.createdAt.toEpochMilliseconds()) + upsertMessageStmt.executeUpdate() + } + } + // Удалённые (нет в new). + for ((id, _) in oldMsgsById) { + if (msgs.none { it.id == id }) { + deleteMessageStmt.reset() + deleteMessageStmt.clearBindings() + deleteMessageStmt.bindText(1, id.raw) + deleteMessageStmt.executeUpdate() + } + } + } + } + + override fun close() { + upsertChatStmt.close() + deleteChatStmt.close() + upsertMessageStmt.close() + deleteMessageStmt.close() + selectChatsStmt.close() + selectMessagesStmt.close() + truncateChatsStmt.close() + truncateMessagesStmt.close() + selectCurrentEpochStmt.close() + } + + companion object { + private const val COL_EPOCH = Schema.COL_EPOCH + private const val COL_CHAT_ID = Schema.COL_CHAT_ID + private const val COL_TITLE = Schema.COL_TITLE + private const val COL_CREATED_AT = Schema.COL_CREATED_AT + private const val COL_DELETED = Schema.COL_DELETED + private const val COL_LAST_SEQ = Schema.COL_LAST_SEQ + private const val COL_MESSAGE_ID = Schema.COL_MESSAGE_ID + private const val COL_ROLE = Schema.COL_ROLE + private const val COL_CONTENT = Schema.COL_CONTENT + private const val COL_PARENT_ID = Schema.COL_PARENT_ID + } +} \ No newline at end of file diff --git a/sync-core/src/commonMain/kotlin/pw/binom/agentik/sync/ksqlite/SqliteSyncBundle.kt b/sync-core/src/commonMain/kotlin/pw/binom/agentik/sync/ksqlite/SqliteSyncBundle.kt new file mode 100644 index 0000000..22adda1 --- /dev/null +++ b/sync-core/src/commonMain/kotlin/pw/binom/agentik/sync/ksqlite/SqliteSyncBundle.kt @@ -0,0 +1,100 @@ +package pw.binom.agentik.sync.ksqlite + +import kotlinx.coroutines.runBlocking +import pw.binom.agentik.sync.CompactionState +import pw.binom.agentik.sync.Cursor +import pw.binom.agentik.sync.EventLogReplica +import pw.binom.agentik.sync.EventLogWriter +import pw.binom.agentik.sync.StateStore +import pw.binom.agentik.sync.SyncEngine +import pw.binom.agentik.sync.SyncTransport +import pw.binom.agentik.sync.memory.InProcessTransport +import pw.binom.agentik.sync.testing.SyncBundle +import pw.binom.db.ksqlite.SQLiteConnection +import kotlin.time.Clock + +/** + * KSQLITE-бэкенд для тестов спеки. Сервер и клиент — на разных SQLite-базах + * (одна in-memory connection shared у сервера, другая — у клиента). + * + * Создаётся через [create] с fresh in-memory SQLite для каждого бандла — + * тесты изолированы друг от друга. + * + * Транспорт — [InProcessTransport], обёрнутый вокруг серверного event log'а. + */ +class SqliteSyncBundle private constructor( + private val serverConn: SQLiteConnection, + private val clientConn: SQLiteConnection, + private val serverLog: SqliteEventLog, + private val srvState: SqliteStateStore, + private val cliState: SqliteStateStore, + private val clientLog: SqliteEventLogReplica, + private val transportImpl: InProcessTransport, +) : SyncBundle() { + + override val server: EventLogWriter = serverLog + override val serverState: StateStore = srvState + override val client: EventLogReplica = clientLog + override val clientState: StateStore = cliState + override val transport: SyncTransport = transportImpl + override val engine: SyncEngine = SyncEngine(replica = clientLog, transport = transportImpl) + + override suspend fun compactionState(): CompactionState = serverLog.compaction() + + override suspend fun compactServer(upTo: Cursor) { + serverLog.compact(upTo) + } + + override suspend fun serverBeginNewEpoch(newEpoch: String): Cursor { + // §2.3 спеки: append на сервере атомарно пишет и в журнал, + // и в материализацию. На beginNewEpoch state-store тоже надо + // очистить. + srvState.replaceState(pw.binom.agentik.sync.StateSnapshot(pw.binom.agentik.sync.ChatState())) + return serverLog.beginNewEpoch(newEpoch) + } + + /** Отдельно сменить эпоху на клиенте (для тестов WRONG_EPOCH-сценария). */ + suspend fun clientBeginNewEpoch(newEpoch: String): Cursor = + clientLog.beginNewEpoch(newEpoch) + + override fun close() { + serverLog.closeStmts() + srvState.close() + clientLog.closeStmts() + cliState.close() + transportImpl.close() + serverConn.close() + clientConn.close() + } + + companion object { + fun create(): SqliteSyncBundle { + val serverConn = SQLiteConnection.memory("server-${Clock.System.now().toEpochMilliseconds()}") + val clientConn = SQLiteConnection.memory("client-${Clock.System.now().toEpochMilliseconds()}") + // Создаём схему. + Schema.migrate(serverConn) + Schema.migrate(clientConn) + // Server. + val serverLog = SqliteEventLog(serverConn, ownsConnection = false) + val serverState = SqliteStateStore(serverConn) + // §2.3 спеки: append на сервере атомарно пишет и в журнал, + // и в материализацию. SqliteEventLog.append уведомляет подписчиков — + // SqliteStateStore подписывается, чтобы обновить свой shadow. + serverLog.onAppend { e -> serverState.applyOnServer(e) } + // Client. + val clientState = SqliteStateStore(clientConn) + val clientLog = SqliteEventLogReplica(clientConn, clientState) + // Транспорт ходит через серверный SqliteEventLog. + val transport = InProcessTransport( + server = serverLog, + stateStore = serverState, + compactionState = { serverLog.compaction() }, + ) + return SqliteSyncBundle( + serverConn, clientConn, + serverLog, serverState, clientState, clientLog, + transport, + ) + } + } +} \ No newline at end of file diff --git a/sync-core/src/commonMain/kotlin/pw/binom/agentik/sync/memory/InMemoryEventLog.kt b/sync-core/src/commonMain/kotlin/pw/binom/agentik/sync/memory/InMemoryEventLog.kt new file mode 100644 index 0000000..cfb6fc0 --- /dev/null +++ b/sync-core/src/commonMain/kotlin/pw/binom/agentik/sync/memory/InMemoryEventLog.kt @@ -0,0 +1,277 @@ +package pw.binom.agentik.sync.memory + +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import pw.binom.agentik.sync.ChatState +import pw.binom.agentik.sync.CompactionState +import pw.binom.agentik.sync.Cursor +import pw.binom.agentik.sync.DefaultChatReducer +import pw.binom.agentik.sync.DomainEvent +import pw.binom.agentik.sync.EventLogReplica +import pw.binom.agentik.sync.EventLogWriter +import pw.binom.agentik.sync.LocalId +import pw.binom.agentik.sync.LoggedEvent +import pw.binom.agentik.sync.PendingEvent +import pw.binom.agentik.sync.PendingStatus +import pw.binom.agentik.sync.Reducer +import pw.binom.agentik.sync.SeqMonotonicityViolationException +import pw.binom.agentik.sync.StateSnapshot +import pw.binom.agentik.sync.StateStore +import pw.binom.agentik.sync.SyncState +import kotlin.time.Clock +import kotlin.time.Instant + +/** + * In-memory реализация [EventLogWriter] + [EventLogReplica] + [StateStore]. + * + * Семантика: + * - [append] атомарно (под мьютексом): резервирует `cursor`, пишет событие + * в `events`, применяет редьюсер к `state`. + * - [compact] удаляет события `cursor.number <= upTo.number` и сдвигает + * `minAvailableCursor`. + * - [applyRemote] на replica применяет редьюсер, идемпотентно по `eventId`. + * - [replaceState] полностью заменяет state и сбрасывает `syncState.cursor`. + * - [beginNewEpoch] стирает все события и обновляет текущую эпоху. + * - pending-события хранятся в отдельной мапе, смена статуса меняет запись. + * + * Для тестов спеки. Не предназначен для production — состояние в памяти + * процесса не переживает рестарт. + */ +class InMemoryEventLog( + private val reducer: Reducer = DefaultChatReducer, + initialEpoch: String = Cursor.ZERO.epoch, +) : EventLogWriter, EventLogReplica, StateStore { + + private val mutex = Mutex() + + /** Журнал событий: append-only. */ + private val events: MutableList = mutableListOf() + /** Для идемпотентности applyRemote: уже применённые `eventId`. */ + private val appliedEventIds: MutableSet = mutableSetOf() + + /** Материализованное состояние (обновляется атомарно с append/applyRemote). */ + private var state: ChatState = ChatState() + + /** Pending-event'ы. Стартуют со статусом PENDING → SENT/FAILED. */ + private val pending: MutableMap = mutableMapOf() + + /** Подписчики на append (используется [InProcessTransport] для live-канала). */ + internal val appendListeners: MutableList Unit> = mutableListOf() + + /** Текущая эпоха. */ + private var currentEpoch: String = initialEpoch + + /** `cursor` синхронизации (= последний успешно apply'нутый). */ + private var syncCursor: Cursor = Cursor(initialEpoch, 0uL) + private var syncLastSyncAt: Instant? = null + + /** Метаданные компакции. */ + private var minAvailableCursor: Cursor = Cursor(initialEpoch, 0uL) + private var lastCompactedAt: Instant? = null + + /** Подписаться на каждое новое append-событие. */ + fun onAppend(listener: suspend (LoggedEvent) -> Unit) { + appendListeners.add(listener) + } + + private suspend fun notifyAppend(l: LoggedEvent) { + for (f in appendListeners.toList()) f(l) + } + + // ─── EventLog (общий для writer и replica) ──────────────────────────── + + override suspend fun readUpdates(after: Cursor, limit: Int): List = mutex.withLock { + // Клиентская сторона не возвращает события — replay на сервере. + // Если кто-то вызвал readUpdates на in-memory "server" — отдадим все. + // Кросс-эпоха → бросаем CursorExpired. + if (after.epoch != currentEpoch) { + throw pw.binom.agentik.sync.CursorExpiredException( + requested = after, + currentEpoch = currentEpoch, + minAvailable = minAvailableCursor, + current = syncCursor, + reason = pw.binom.agentik.sync.CursorExpiredReason.WRONG_EPOCH, + ) + } + if (after.number < minAvailableCursor.number) { + throw pw.binom.agentik.sync.CursorExpiredException( + requested = after, + currentEpoch = currentEpoch, + minAvailable = minAvailableCursor, + current = syncCursor, + reason = pw.binom.agentik.sync.CursorExpiredReason.TOO_OLD, + ) + } + events.asSequence() + .filter { it.cursor.epoch == after.epoch && it.cursor.number > after.number } + .take(limit) + .toList() + } + + override suspend fun lastCursor(): Cursor = mutex.withLock { + syncCursor + } + + override suspend fun compaction(): CompactionState = mutex.withLock { + CompactionState( + currentEpoch = currentEpoch, + minAvailableCursor = minAvailableCursor, + lastCompactedAt = lastCompactedAt, + ) + } + + // ─── EventLogWriter (сервер) ─────────────────────────────────────────── + + override suspend fun append(event: DomainEvent): LoggedEvent = mutex.withLock { + if (event.eventId in appliedEventIds) { + return@withLock events.first { it.eventId == event.eventId } + } + // Резервируем следующий cursor в текущей эпохе. + val nextNumber = (events.lastOrNull()?.cursor?.number ?: 0uL) + 1uL + val cursor = Cursor(currentEpoch, nextNumber) + val logged = LoggedEvent( + cursor = cursor, + eventId = event.eventId, + payload = event, + createdAt = Clock.System.now(), + ) + state = reducer.reduce(state, logged) + events.add(logged) + appliedEventIds.add(event.eventId) + syncCursor = cursor + notifyAppend(logged) + logged + } + + override suspend fun compact(upTo: Cursor) = mutex.withLock { + require(upTo.epoch == currentEpoch) { "compact(upTo=$upTo) requires epoch=$currentEpoch" } + events.removeAll { it.cursor.number <= upTo.number } + val newMin = upTo.next() + if (newMin.number > minAvailableCursor.number) { + minAvailableCursor = newMin + } + lastCompactedAt = Clock.System.now() + } + + override suspend fun beginNewEpoch(newEpoch: String): Cursor = mutex.withLock { + require(newEpoch.isNotEmpty()) { "epoch must be non-empty" } + require('-' !in newEpoch) { "epoch must not contain '-': \"$newEpoch\"" } + events.clear() + appliedEventIds.clear() + state = ChatState() + currentEpoch = newEpoch + syncCursor = Cursor(newEpoch, 0uL) + minAvailableCursor = Cursor(newEpoch, 0uL) + lastCompactedAt = null + syncCursor + } + + // ─── EventLogReplica (клиент) ────────────────────────────────────────── + + override suspend fun applyRemote(event: LoggedEvent) = mutex.withLock { + if (event.eventId in appliedEventIds) return@withLock + if (event.cursor.epoch != currentEpoch) { + // Клиент видит событие другой эпохи → либо он сам отстал (нужен + // replaceState), либо сервер сделал wipe. В обоих случаях клиент + // должен делать replaceState через SyncEngine, а не applyRemote. + // Здесь бросаем, чтобы SyncEngine заметил gap и пошёл в fetchState. + throw SeqMonotonicityViolationException( + previous = syncCursor.number.toLong(), + attempted = event.cursor.number.toLong(), + ) + } + if (event.cursor.number <= syncCursor.number) { + throw SeqMonotonicityViolationException( + previous = syncCursor.number.toLong(), + attempted = event.cursor.number.toLong(), + ) + } + state = reducer.reduce(state, event) + events.add(event) + appliedEventIds.add(event.eventId) + syncCursor = event.cursor + syncLastSyncAt = Clock.System.now() + } + + override suspend fun replaceState(snapshot: StateSnapshot) = mutex.withLock { + state = snapshot.state + currentEpoch = snapshot.state.lastCursor.epoch + syncCursor = snapshot.state.lastCursor + syncLastSyncAt = Clock.System.now() + } + + override suspend fun pendingEvents(): List = mutex.withLock { + pending.values.filter { it.status == PendingStatus.PENDING } + } + + override suspend fun enqueuePending(payload: DomainEvent, createdAt: Instant): PendingEvent = mutex.withLock { + val pe = PendingEvent( + localId = LocalId(raw = payload.eventId), + payload = payload, + createdAt = createdAt, + status = PendingStatus.PENDING, + ) + pending[pe.localId] = pe + // Оптимистичное применение к локальному состоянию — UI рисует сразу. + val fakeCursor = syncCursor.next() + val logged = LoggedEvent( + cursor = fakeCursor, + eventId = payload.eventId, + payload = payload, + createdAt = createdAt, + ) + state = reducer.reduce(state, logged) + events.add(logged) + syncCursor = fakeCursor + pe + } + + override suspend fun markPendingAsSent(localId: LocalId, cursor: Cursor) = mutex.withLock { + val pe = pending[localId] ?: return@withLock + pending[localId] = pe.copy(status = PendingStatus.SENT, sentCursor = cursor) + if (cursor.number > syncCursor.number && cursor.epoch == currentEpoch) { + syncCursor = cursor + } + } + + override suspend fun markPendingAsFailed(localId: LocalId) = mutex.withLock { + val pe = pending[localId] ?: return@withLock + pending[localId] = pe.copy(status = PendingStatus.FAILED) + } + + override suspend fun syncState(): SyncState = mutex.withLock { + SyncState(cursor = syncCursor, lastSyncAt = syncLastSyncAt) + } + + // ─── StateStore ──────────────────────────────────────────────────────── + + override suspend fun applyOnServer(event: LoggedEvent) = mutex.withLock { + if (event.eventId in appliedEventIds) return@withLock + state = reducer.reduce(state, event) + appliedEventIds.add(event.eventId) + } + + override suspend fun applyOnClient(event: LoggedEvent) = applyRemote(event) + + override suspend fun readState(): StateSnapshot = mutex.withLock { + StateSnapshot(state = state) + } + + /** Алиас [lastCursor] для [StateStore]. */ + suspend fun materializationLastCursor(): Cursor = mutex.withLock { state.lastCursor } + + // ─── Тест-only инспекция ──────────────────────────────────────────────── + + /** Размер журнала (read-only). */ + suspend fun journalSize(): Int = mutex.withLock { events.size } + + /** Все pending-события (любого статуса). */ + suspend fun allPending(): List = mutex.withLock { pending.values.toList() } + + /** Текущая эпоха (read-only). */ + suspend fun epoch(): String = mutex.withLock { currentEpoch } + + override fun close() { + // Ничего не делаем — in-memory. + } +} \ No newline at end of file diff --git a/sync-core/src/commonMain/kotlin/pw/binom/agentik/sync/memory/InProcessTransport.kt b/sync-core/src/commonMain/kotlin/pw/binom/agentik/sync/memory/InProcessTransport.kt new file mode 100644 index 0000000..f2c8c8d --- /dev/null +++ b/sync-core/src/commonMain/kotlin/pw/binom/agentik/sync/memory/InProcessTransport.kt @@ -0,0 +1,156 @@ +package pw.binom.agentik.sync.memory + +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import pw.binom.agentik.sync.CompactionState +import pw.binom.agentik.sync.Cursor +import pw.binom.agentik.sync.CursorExpiredException +import pw.binom.agentik.sync.CursorExpiredReason +import pw.binom.agentik.sync.DomainEvent +import pw.binom.agentik.sync.EventLogWriter +import pw.binom.agentik.sync.LoggedEvent +import pw.binom.agentik.sync.PendingEvent +import pw.binom.agentik.sync.PostResult +import pw.binom.agentik.sync.StateResponse +import pw.binom.agentik.sync.StateStore +import pw.binom.agentik.sync.SyncTransport +import pw.binom.agentik.sync.UpdatesResponse +import kotlin.time.Clock + +/** + * In-memory реализация [SyncTransport] поверх одного [EventLogWriter] + + * [StateStore]. Имитирует HTTP/WS семантику спеки без сетевого слоя — + * нужно для тестов. + * + * Семантика: + * - [fetchUpdates] повторяет логику §5.2 шагов 2-4: + * * `after.epoch != current_epoch` → [UpdatesResponse.CursorExpired] с + * [CursorExpiredReason.WRONG_EPOCH]. + * * `after.number < minAvailable.number` (та же эпоха) → + * [UpdatesResponse.CursorExpired] с [CursorExpiredReason.TOO_OLD]. + * * иначе — окно с `cursor > after`, не больше `limit`, и + * `hasMore = (окно.size == limit)`. + * - [fetchState] отдаёт полный снапшот через [StateStore.readState]. + * - [postPending] атомарно `append'`ит каждое событие; идемпотентность + * по `eventId` (см. `InMemoryEventLog.append`). + * - [subscribeLive] — холодный Flow, отдаёт события `cursor > after` и + * подписывается на broadcast-канал. При пропущенном курсоре сразу + * бросает [CursorExpiredException] внутрь Flow. + */ +class InProcessTransport( + private val server: EventLogWriter, + private val stateStore: StateStore, + private val compactionState: suspend () -> CompactionState, +) : SyncTransport { + + private val mutex = Mutex() + + /** Broadcast-канал для live-подписок. `extraBufferCapacity = 64` — окно для подписчиков. */ + private val live = MutableSharedFlow( + replay = 0, + extraBufferCapacity = 64, + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) + + init { + // Подписываемся на append'ы writer'а (in-memory impl поддерживает + // listener'ов через [InMemoryEventLog.onAppend]). + (server as? InMemoryEventLog)?.onAppend { live.emit(it) } + } + + /** Append с уведомлением подписчиков. */ + suspend fun appendAndBroadcast(event: DomainEvent): LoggedEvent { + val logged = server.append(event) + live.emit(logged) + return logged + } + + override suspend fun fetchUpdates(after: Cursor, limit: Int): UpdatesResponse = mutex.withLock { + val compaction = compactionState() + val current = server.lastCursor() + if (after.epoch != compaction.currentEpoch) { + return@withLock UpdatesResponse.CursorExpired( + requested = after, + minAvailable = compaction.minAvailableCursor, + current = current, + reason = CursorExpiredReason.WRONG_EPOCH, + ) + } + if (after.number < compaction.minAvailableCursor.number) { + return@withLock UpdatesResponse.CursorExpired( + requested = after, + minAvailable = compaction.minAvailableCursor, + current = current, + reason = CursorExpiredReason.TOO_OLD, + ) + } + val events = server.readUpdates(after, limit) + val hasMore = events.size == limit + UpdatesResponse.Ok( + events = events, + hasMore = hasMore, + currentCursor = current, + ) + } + + override suspend fun fetchState(): StateResponse { + val snap = stateStore.readState() + val cur = server.lastCursor() + return StateResponse.Ok( + snapshot = snap, + upToCursor = snap.state.lastCursor, + currentCursor = cur, + ) + } + + override suspend fun postPending(events: List): List { + val out = ArrayList(events.size) + for (pe in events) { + try { + val logged = appendAndBroadcast(pe.payload) + out.add(PostResult.Accepted(localId = pe.localId, cursor = logged.cursor)) + } catch (t: Throwable) { + out.add(PostResult.Rejected(localId = pe.localId, reason = t.message ?: "unknown")) + } + } + return out + } + + override fun subscribeLive(after: Cursor): Flow { + val min = runBlocking { compactionState() } + val current = runBlocking { server.lastCursor() } + if (after.epoch != min.currentEpoch) { + throw CursorExpiredException( + requested = after, + currentEpoch = min.currentEpoch, + minAvailable = min.minAvailableCursor, + current = current, + reason = CursorExpiredReason.WRONG_EPOCH, + ) + } + if (after.number < min.minAvailableCursor.number) { + throw CursorExpiredException( + requested = after, + currentEpoch = min.currentEpoch, + minAvailable = min.minAvailableCursor, + current = current, + reason = CursorExpiredReason.TOO_OLD, + ) + } + return live.onStart { + val catchup = server.readUpdates(after, limit = Int.MAX_VALUE) + for (e in catchup) emit(e) + } + } + + override fun close() { + // No-op. + } +} + +private fun runBlocking(block: suspend () -> T): T = + kotlinx.coroutines.runBlocking { block() } \ No newline at end of file diff --git a/sync-core/src/commonMain/kotlin/pw/binom/agentik/sync/testing/SyncBundle.kt b/sync-core/src/commonMain/kotlin/pw/binom/agentik/sync/testing/SyncBundle.kt new file mode 100644 index 0000000..9706e8d --- /dev/null +++ b/sync-core/src/commonMain/kotlin/pw/binom/agentik/sync/testing/SyncBundle.kt @@ -0,0 +1,59 @@ +package pw.binom.agentik.sync.testing + +import pw.binom.agentik.sync.ChatState +import pw.binom.agentik.sync.CompactionState +import pw.binom.agentik.sync.Cursor +import pw.binom.agentik.sync.EventLogReplica +import pw.binom.agentik.sync.EventLogWriter +import pw.binom.agentik.sync.StateStore +import pw.binom.agentik.sync.SyncEngine +import pw.binom.agentik.sync.SyncTransport + +/** + * Контракт бэкенда для тестов спеки. Любая имплементация (in-memory, + * ksqlite и т.п.) создаёт через `create()` (см. `MemorySyncBundle`, + * `SqliteSyncBundle`) изолированный мир: сервер (writer + state store + + * transport) + клиент (replica) + sync-engine. + * + * Тесты в этом пакете вызывают только методы бандла и проверяют инварианты + * спеки, не зная, что внутри. Это позволяет одной и той же тестовой сюитой + * прогонять и in-memory, и ksqlite impl. + */ +abstract class SyncBundle : AutoCloseable { + + abstract val server: EventLogWriter + abstract val serverState: StateStore + abstract val client: EventLogReplica + abstract val clientState: StateStore + abstract val transport: SyncTransport + abstract val engine: SyncEngine + + /** Снапшот серверного compaction-стейта — для тестов, которые его + * трогают напрямую. */ + abstract suspend fun compactionState(): CompactionState + + /** Принудительный compact серверного журнала (используется тестами + * сценария «cursor протух»). */ + abstract suspend fun compactServer(upTo: Cursor) + + /** Принудительная смена эпохи на сервере (для тестов WRONG_EPOCH). + * Возвращает новый стартовый Cursor. */ + abstract suspend fun serverBeginNewEpoch(newEpoch: String): Cursor + + /** Клиентский lastCursor (для тестов). */ + suspend fun clientLastCursor(): Cursor = client.syncState().cursor + + /** Серверный lastCursor. */ + suspend fun serverLastCursor(): Cursor = server.lastCursor() + + /** Серверный снапшот. */ + suspend fun serverReadState(): ChatState = serverState.readState().state + + /** Клиентский снапшот. */ + suspend fun clientReadState(): ChatState = clientState.readState().state + + override fun close() { + // По умолчанию ничего — конкретные бандлы (in-memory, ksqlite) + // переопределяют. + } +} \ No newline at end of file diff --git a/sync-core/src/commonTest/kotlin/pw/binom/agentik/sync/ksqlite/KsqlitePersistenceTest.kt b/sync-core/src/commonTest/kotlin/pw/binom/agentik/sync/ksqlite/KsqlitePersistenceTest.kt new file mode 100644 index 0000000..f92de50 --- /dev/null +++ b/sync-core/src/commonTest/kotlin/pw/binom/agentik/sync/ksqlite/KsqlitePersistenceTest.kt @@ -0,0 +1,55 @@ +package pw.binom.agentik.sync.ksqlite + +import kotlinx.io.files.Path +import kotlinx.io.files.SystemFileSystem +import pw.binom.agentik.sync.ChatId +import pw.binom.agentik.sync.Cursor +import pw.binom.agentik.sync.test.samples +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.time.Clock + +/** + * Тест на персистентность ksqlite-имплёмплементации: данные, записанные в одной + * сессии, доступны после полного закрытия соединения и открытия нового. + * Использует kotlinx-io для работы с файлами — поэтому живёт в commonTest + * и прогоняется на всех целях, где есть ksqlite (jvm, linuxX64, mingwX64). + */ +class KsqlitePersistenceTest { + + @Test + fun `данные сохраняются в SQLite и доступны после перезагрузки`() = kotlinx.coroutines.runBlocking { + val tempFile = Path("/tmp", "opencode", "sync-core-test-${Clock.System.now().toEpochMilliseconds()}.db") + SystemFileSystem.createDirectories(tempFile.parent!!) + try { + val conn1 = pw.binom.db.ksqlite.SQLiteConnection.open(tempFile.toString()) + Schema.migrate(conn1) + val log1 = SqliteEventLog(conn1, ownsConnection = false) + val state1 = SqliteStateStore(conn1) + log1.append(samples.chatCreated(0)) + log1.append(samples.messageAppended(0, 0, content = "persistent")) + val events = log1.readUpdates(Cursor.ZERO, 100) + state1.applyOnServer(events[0]) + state1.applyOnServer(events[1]) + log1.closeStmts() + state1.close() + conn1.close() + + val conn2 = pw.binom.db.ksqlite.SQLiteConnection.open(tempFile.toString()) + val log2 = SqliteEventLog(conn2, ownsConnection = false) + val state2 = SqliteStateStore(conn2) + val events2 = log2.readUpdates(Cursor.ZERO, 100) + assertEquals(2, events2.size) + val snap = state2.readState() + assertNotNull(snap.state.chats[ChatId("chat0")]) + assertEquals(1, snap.state.messages[ChatId("chat0")]?.size) + assertEquals("persistent", snap.state.messages[ChatId("chat0")]?.single()?.content) + log2.closeStmts() + state2.close() + conn2.close() + } finally { + SystemFileSystem.delete(tempFile, mustExist = false) + } + } +} \ No newline at end of file diff --git a/sync-core/src/commonTest/kotlin/pw/binom/agentik/sync/ksqlite/KsqliteSyncSpecTest.kt b/sync-core/src/commonTest/kotlin/pw/binom/agentik/sync/ksqlite/KsqliteSyncSpecTest.kt new file mode 100644 index 0000000..1c9fdaf --- /dev/null +++ b/sync-core/src/commonTest/kotlin/pw/binom/agentik/sync/ksqlite/KsqliteSyncSpecTest.kt @@ -0,0 +1,229 @@ +package pw.binom.agentik.sync.ksqlite + +import pw.binom.agentik.sync.ChatId +import pw.binom.agentik.sync.Cursor +import pw.binom.agentik.sync.UpdatesResponse +import pw.binom.agentik.sync.test.samples +import pw.binom.agentik.sync.test.someInstant +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFails +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * Интеграционные тесты спеки `SYNC-SYSTEM.md` против реальной SQLite (через + * ksqlite). 15 in-memory тестов — в commonTest, чтобы прогонялись на всех + * KMP-целях, где ksqlite публикуется (jvm, linuxX64, mingwX64). + */ +class KsqliteSyncSpecTest { + + private fun bundle() = SqliteSyncBundle.create() + + @Test + fun `1 cursor монотонно возрастает на append`() = kotlinx.coroutines.runBlocking { + val b = bundle() + val s1 = b.server.append(samples.chatCreated(0)) + val s2 = b.server.append(samples.messageAppended(0, 0)) + val s3 = b.server.append(samples.messageAppended(0, 1)) + assertTrue(s1.cursor < s2.cursor) + assertTrue(s2.cursor < s3.cursor) + assertEquals(s1.cursor.next(), s2.cursor) + assertEquals(s2.cursor.next(), s3.cursor) + b.close() + } + + @Test + fun `2 события не изменяются после append`() = kotlinx.coroutines.runBlocking { + val b = bundle() + val event = samples.chatCreated(0) + val logged = b.server.append(event) + val read = b.server.readUpdates(Cursor.ZERO, 100).single() + assertEquals(logged.cursor, read.cursor) + assertEquals(logged.eventId, read.eventId) + assertEquals(logged.payload, read.payload) + b.close() + } + + @Test + fun `3 материализация сервера консистентна журналу`() = kotlinx.coroutines.runBlocking { + val b = bundle() + b.server.append(samples.chatCreated(0)) + b.server.append(samples.messageAppended(0, 0)) + b.server.append(samples.messageAppended(0, 1)) + val state = b.serverReadState() + assertNotNull(state.chats[ChatId("chat0")]) + assertEquals(2, state.messages[ChatId("chat0")]?.size) + b.close() + } + + @Test + fun `4 событие несёт полный payload через SQL`() = kotlinx.coroutines.runBlocking { + val b = bundle() + val created = pw.binom.agentik.sync.DomainEvent.ChatCreated( + eventId = "ev-test", + chatId = ChatId("chatX"), + title = "Полный заголовок с Unicode", + createdAt = someInstant, + ) + val logged = b.server.append(created) + val read = b.server.readUpdates(Cursor.ZERO, 100).single() + assertEquals("Полный заголовок с Unicode", (read.payload as pw.binom.agentik.sync.DomainEvent.ChatCreated).title) + b.close() + } + + @Test + fun `5 replaceState полностью заменяет state`() = kotlinx.coroutines.runBlocking { + val b = bundle() + b.server.append(samples.chatCreated(0)) + b.server.append(samples.chatCreated(1)) + b.server.append(samples.messageAppended(0, 0)) + b.engine.sync() + assertEquals(2, b.clientReadState().chats.size) + b.server.append(samples.chatDeleted(0)) + b.client.replaceState(pw.binom.agentik.sync.StateSnapshot(pw.binom.agentik.sync.ChatState())) + b.compactServer(upTo = b.serverLastCursor()) + b.engine.sync() + val chats = b.clientReadState().chats + assertEquals(1, chats.size) + assertNotNull(chats[ChatId("chat1")]) + assertEquals(null, chats[ChatId("chat0")]) + b.close() + } + + @Test + fun `6 clientReadState совпадает с serverReadState после sync`() = kotlinx.coroutines.runBlocking { + val b = bundle() + b.server.append(samples.chatCreated(0)) + b.server.append(samples.messageAppended(0, 0)) + b.server.append(samples.chatCreated(1)) + b.server.append(samples.messageAppended(1, 0)) + b.engine.sync() + assertEquals(b.serverReadState(), b.clientReadState()) + b.close() + } + + @Test + fun `7 compact делает cursor протухшим - sync делает replace`() = kotlinx.coroutines.runBlocking { + val b = bundle() + b.server.append(samples.chatCreated(0)) + b.server.append(samples.messageAppended(0, 0)) + b.engine.sync() + val clientCursor = b.clientLastCursor() + b.server.append(samples.messageAppended(0, 1)) + b.server.append(samples.messageAppended(0, 2)) + b.compactServer(upTo = clientCursor) + val resp = b.transport.fetchUpdates(clientCursor, 100) + assertTrue(resp is UpdatesResponse.CursorExpired) + val report = b.engine.sync() + assertTrue(report.resolvedCursorGap) + assertEquals(3, b.clientReadState().messages[ChatId("chat0")]?.size) + b.close() + } + + @Test + fun `повторный append с тем же eventId возвращает тот же cursor`() = kotlinx.coroutines.runBlocking { + val b = bundle() + val e = samples.chatCreated(0) + val first = b.server.append(e) + val second = b.server.append(e) + assertEquals(first.cursor, second.cursor) + b.close() + } + + @Test + fun `applyRemote идемпотентен по eventId`() = kotlinx.coroutines.runBlocking { + val b = bundle() + b.server.append(samples.chatCreated(0)) + b.server.append(samples.messageAppended(0, 0)) + b.engine.sync() + val before = b.clientReadState() + val first = b.server.readUpdates(Cursor.ZERO, 100).first() + b.client.applyRemote(first) + assertEquals(before, b.clientReadState()) + b.close() + } + + @Test + fun `события упорядочены и без дыр`() = kotlinx.coroutines.runBlocking { + val b = bundle() + for (i in 0..10) b.server.append(samples.messageAppended(0, i)) + val events = b.server.readUpdates(Cursor.ZERO, 100) + for (i in 1 until events.size) { + assertEquals(events[i - 1].cursor.next(), events[i].cursor) + } + b.close() + } + + @Test + fun `applyRemote вне порядка бросает исключение`() = kotlinx.coroutines.runBlocking { + val b = bundle() + b.server.append(samples.chatCreated(0)) + b.engine.sync() + val past = pw.binom.agentik.sync.LoggedEvent( + cursor = b.clientLastCursor(), + eventId = "old-event", + payload = samples.messageAppended(0, 99), + createdAt = someInstant, + ) + assertFails { b.client.applyRemote(past) } + b.close() + } + + @Test + fun `compact удаляет старые события и сдвигает minAvailableCursor`() = kotlinx.coroutines.runBlocking { + val b = bundle() + b.server.append(samples.chatCreated(0)) + b.server.append(samples.messageAppended(0, 0)) + b.server.append(samples.messageAppended(0, 1)) + val epoch = Cursor.ZERO.epoch + b.compactServer(upTo = Cursor(epoch, 1uL)) + assertEquals(Cursor(epoch, 2uL), b.compactionState().minAvailableCursor) + // После compact cursor < min → CursorExpired. + val resp = b.transport.fetchUpdates(Cursor.ZERO, 100) + assertTrue(resp is UpdatesResponse.CursorExpired) + // Сверх floor: after=c(2) → events с cursor.number > 2 → number=3. + val after2 = b.transport.fetchUpdates(Cursor(epoch, 2uL), 100) + assertTrue(after2 is UpdatesResponse.Ok) + val ok = after2 as UpdatesResponse.Ok + assertEquals(1, ok.events.size) + assertEquals(3uL, ok.events.single().cursor.number) + b.close() + } + + @Test + fun `enqueuePending оптимистично применяется`() = kotlinx.coroutines.runBlocking { + val b = bundle() + b.server.append(samples.chatCreated(0)) + b.client.enqueuePending(samples.messageAppended(0, 0, content = "offline"), someInstant) + val msgs = b.clientReadState().messages[ChatId("chat0")].orEmpty() + assertEquals(1, msgs.size) + assertEquals("offline", msgs.single().content) + b.close() + } + + @Test + fun `sync отправляет pending на сервер`() = kotlinx.coroutines.runBlocking { + val b = bundle() + b.server.append(samples.chatCreated(0)) + b.client.enqueuePending(samples.messageAppended(0, 0, content = "offline"), someInstant) + val report = b.engine.sync() + assertEquals(1, report.postedPending) + val serverMsgs = b.serverReadState().messages[ChatId("chat0")].orEmpty() + assertEquals(1, serverMsgs.size) + b.close() + } + + @Test + fun `pending сохраняется после replaceState`() = kotlinx.coroutines.runBlocking { + val b = bundle() + b.server.append(samples.chatCreated(0)) + b.client.enqueuePending(samples.messageAppended(0, 0, content = "keep"), someInstant) + b.client.replaceState(pw.binom.agentik.sync.StateSnapshot(pw.binom.agentik.sync.ChatState())) + assertEquals(1, b.client.pendingEvents().size) + b.engine.sync() + assertEquals(1, b.serverReadState().messages[ChatId("chat0")]?.size) + b.close() + } +} \ No newline at end of file diff --git a/sync-core/src/commonTest/kotlin/pw/binom/agentik/sync/test/AssistantTest.kt b/sync-core/src/commonTest/kotlin/pw/binom/agentik/sync/test/AssistantTest.kt new file mode 100644 index 0000000..3c63e06 --- /dev/null +++ b/sync-core/src/commonTest/kotlin/pw/binom/agentik/sync/test/AssistantTest.kt @@ -0,0 +1,88 @@ +package pw.binom.agentik.sync.test + +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.launch +import kotlinx.coroutines.yield +import pw.binom.agentik.sync.Assistant +import pw.binom.agentik.sync.ChatId +import pw.binom.agentik.sync.DomainEvent +import pw.binom.agentik.sync.MessageId +import pw.binom.agentik.sync.RunInput +import pw.binom.agentik.sync.remoteAssistant +import pw.binom.agentik.sync.scriptedAssistant +import pw.binom.agentik.sync.test.memory.InMemorySyncBundle +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * Проверки §4.4 (Assistant) и §5.4 (LocalAssistant). + */ +class AssistantTest { + + @Test + fun `LocalAssistant возвращает поток доменных событий`() = kotlinx.coroutines.runBlocking { + val events = listOf( + samples.messageAppended(0, 0, content = "user asks"), + samples.messageAppended(0, 1, content = "assistant replies"), + ) + val a: Assistant = scriptedAssistant(events) + val out = a.run(input()).toList() + assertEquals(events, out) + } + + @Test + fun `RemoteAssistant подписывается на live-канал сервера`() = kotlinx.coroutines.runBlocking { + val b = InMemorySyncBundle.create() + b.server.append(samples.chatCreated(0)) + + val assistant: Assistant = remoteAssistant(b.transport) { b.server.lastCursor() } + + val collected = mutableListOf() + val job = launch { + assistant.run(input()).collect { collected.add(it) } + } + // Дать job запуститься и подписаться на live. + yield() + delay(20) + // Сервер публикует событие — assistant его видит. + b.server.append(samples.messageAppended(0, 0, content = "from-server")) + delay(50) + job.cancel() + assertTrue(collected.any { (it as? DomainEvent.MessageAppended)?.content == "from-server" }) + b.close() + } + + @Test + fun `события LocalAssistant попадают в PendingEvent на клиенте`() = kotlinx.coroutines.runBlocking { + val b = InMemorySyncBundle.create() + b.server.append(samples.chatCreated(0)) + + val assistant: Assistant = scriptedAssistant(listOf( + samples.messageAppended(0, 0, content = "user"), + samples.messageAppended(0, 1, content = "assistant"), + )) + + assistant.run(input()).collect { e -> + b.client.enqueuePending(e, someInstant) + } + + val pending = b.client.pendingEvents() + assertEquals(2, pending.size) + val msgs = b.clientReadState().messages.values.flatten() + assertEquals(2, msgs.size) + + val report = b.engine.sync() + assertEquals(2, report.postedPending) + val serverMsgs = b.serverReadState().messages.values.flatten() + assertEquals(2, serverMsgs.size) + b.close() + } + + private fun input() = RunInput( + chatId = ChatId("chat0"), + userMessageId = MessageId("u1"), + reasoning = "test", + ) +} \ No newline at end of file diff --git a/sync-core/src/commonTest/kotlin/pw/binom/agentik/sync/test/CompactionAndOrderTest.kt b/sync-core/src/commonTest/kotlin/pw/binom/agentik/sync/test/CompactionAndOrderTest.kt new file mode 100644 index 0000000..fedbb95 --- /dev/null +++ b/sync-core/src/commonTest/kotlin/pw/binom/agentik/sync/test/CompactionAndOrderTest.kt @@ -0,0 +1,157 @@ +package pw.binom.agentik.sync.test + +import pw.binom.agentik.sync.Cursor +import pw.binom.agentik.sync.LoggedEvent +import pw.binom.agentik.sync.SeqMonotonicityViolationException +import pw.binom.agentik.sync.UpdatesResponse +import pw.binom.agentik.sync.test.memory.InMemorySyncBundle +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFails +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * Проверки §6 (компакция) и §8.2 (порядок событий). + */ +class CompactionAndOrderTest { + + private fun epoch() = Cursor.ZERO.epoch + private fun c(n: Int) = Cursor(epoch(), n.toULong()) + + @Test + fun `compact удаляет старые события и сдвигает minAvailableCursor`() = kotlinx.coroutines.runBlocking { + val b = InMemorySyncBundle.create() + b.server.append(samples.chatCreated(0)) + b.server.append(samples.messageAppended(0, 0)) + b.server.append(samples.messageAppended(0, 1)) + b.compactServer(upTo = c(1)) + val compState = b.compactionState() + assertEquals(c(2), compState.minAvailableCursor) + // После compact курсор ниже min → CursorExpired (§6.3). + val resp = b.transport.fetchUpdates(Cursor.ZERO, 100) + assertTrue(resp is UpdatesResponse.CursorExpired) + // А с курсором >= minAvailableCursor — читается (events with cursor > after). + // after=c(2) → events с cursor.number > 2 → только number=3. + val after2 = b.transport.fetchUpdates(c(2), 100) + assertTrue(after2 is UpdatesResponse.Ok) + val ok = after2 as UpdatesResponse.Ok + assertEquals(1, ok.events.size) + assertEquals(3uL, ok.events.single().cursor.number) + b.close() + } + + @Test + fun `compact не трогает события после upTo`() = kotlinx.coroutines.runBlocking { + val b = InMemorySyncBundle.create() + for (i in 0..5) b.server.append(samples.messageAppended(0, i)) + b.compactServer(upTo = c(3)) + // Чтение с c(4) — события начиная с number=5 (events with cursor > after). + val all = b.transport.fetchUpdates(c(4), 100) + assertTrue(all is UpdatesResponse.Ok) + val evs = (all as UpdatesResponse.Ok).events + assertEquals(listOf(5uL, 6uL), evs.map { it.cursor.number }) + b.close() + } + + @Test + fun `cursor ниже minAvailableCursor дает CursorExpired`() = kotlinx.coroutines.runBlocking { + val b = InMemorySyncBundle.create() + b.server.append(samples.chatCreated(0)) + b.server.append(samples.messageAppended(0, 0)) + b.compactServer(upTo = c(1)) + // after=c(0) < minAvailable=c(2) → CursorExpired. + val resp = b.transport.fetchUpdates(Cursor.ZERO, 100) + assertTrue(resp is UpdatesResponse.CursorExpired) + b.close() + } + + @Test + fun `cursor ровно на minAvailableCursor дает Ok`() = kotlinx.coroutines.runBlocking { + // minAvailable=c(2) после compact(upTo=c(1)); запрос after=c(2) (= min) → cursor > c(2) → пусто. + // CursorExpired НЕ возвращается — граница включительная. + val b = InMemorySyncBundle.create() + b.server.append(samples.chatCreated(0)) // cursor.number=1 + b.server.append(samples.messageAppended(0, 0)) // cursor.number=2 + b.compactServer(upTo = c(1)) + assertEquals(c(2), b.compactionState().minAvailableCursor) + val resp = b.transport.fetchUpdates(c(2), 100) + assertTrue(resp is UpdatesResponse.Ok) + val ok = resp as UpdatesResponse.Ok + // events с cursor > c(2) — пусто. + assertEquals(0, ok.events.size) + b.close() + } + + @Test + fun `cursor ниже minAvailableCursor на 1 дает CursorExpired`() = kotlinx.coroutines.runBlocking { + val b = InMemorySyncBundle.create() + b.server.append(samples.chatCreated(0)) // cursor.number=1 + b.server.append(samples.messageAppended(0, 0)) // cursor.number=2 + b.compactServer(upTo = c(1)) + // after=c(1) < min=c(2) → CursorExpired. + val resp = b.transport.fetchUpdates(c(1), 100) + assertTrue(resp is UpdatesResponse.CursorExpired) + b.close() + } + + @Test + fun `события в журнале строго упорядочены`() = kotlinx.coroutines.runBlocking { + val b = InMemorySyncBundle.create() + for (i in 0..10) b.server.append(samples.messageAppended(0, i)) + val events = b.server.readUpdates(Cursor.ZERO, 100) + assertEquals(events.size, events.distinctBy { it.cursor }.size, "cursor уникальны") + val nums = events.map { it.cursor.number } + assertEquals(nums.sorted(), nums, "numbers отсортированы по возрастанию") + for (i in 1 until events.size) { + assertEquals(events[i - 1].cursor.next(), events[i].cursor, "нет дыр") + } + b.close() + } + + @Test + fun `applyRemote вне порядка бросает исключение`() = kotlinx.coroutines.runBlocking { + val b = InMemorySyncBundle.create() + b.server.append(samples.chatCreated(0)) + b.engine.sync() + // Пробуем применить event с cursor меньшим, чем clientLastCursor. + val past = LoggedEvent( + cursor = b.clientLastCursor(), + eventId = "old-event", + payload = samples.messageAppended(0, 99), + createdAt = someInstant, + ) + assertFails { + b.client.applyRemote(past) + } + // Sanity: исключение именно монотонности. + runCatching { b.client.applyRemote(past) }.onFailure { + assertTrue(it is SeqMonotonicityViolationException, "got ${it::class.simpleName}") + } + b.close() + } + + @Test + fun `fetchUpdates с limit меньше общего числа возвращает hasMore=true`() = kotlinx.coroutines.runBlocking { + val b = InMemorySyncBundle.create() + for (i in 0..4) b.server.append(samples.messageAppended(0, i)) + val resp = b.transport.fetchUpdates(Cursor.ZERO, limit = 2) + assertTrue(resp is UpdatesResponse.Ok) + val ok = resp as UpdatesResponse.Ok + assertEquals(2, ok.events.size) + assertTrue(ok.hasMore) + b.close() + } + + @Test + fun `sync догружает события порциями`() = kotlinx.coroutines.runBlocking { + val b = InMemorySyncBundle.create() + for (i in 0..5) b.server.append(samples.messageAppended(0, i)) + // pageSize=2 — должно быть 3 итерации цикла. + b.engine.sync(pageSize = 2) + val msgs = b.clientReadState().messages.values.flatten() + assertEquals(6, msgs.size) + assertEquals(b.serverLastCursor(), b.clientLastCursor()) + b.close() + } +} \ No newline at end of file diff --git a/sync-core/src/commonTest/kotlin/pw/binom/agentik/sync/test/CursorTest.kt b/sync-core/src/commonTest/kotlin/pw/binom/agentik/sync/test/CursorTest.kt new file mode 100644 index 0000000..b1b2803 --- /dev/null +++ b/sync-core/src/commonTest/kotlin/pw/binom/agentik/sync/test/CursorTest.kt @@ -0,0 +1,123 @@ +package pw.binom.agentik.sync.test + +import pw.binom.agentik.sync.Cursor +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNotEquals + +/** + * Тесты [Cursor] — компактного (epoch + number) курсора журнала. Эпоха + * задаёт «поколение» журнала (меняется при wipe/restore сервера), + * `number` — монотонный счётчик внутри эпохи. + */ +class CursorTest { + + @Test + fun `constructor строит epoch-number_hex`() { + val c = Cursor(epoch = "v1", number = 42uL) + assertEquals("v1", c.epoch) + assertEquals(42uL, c.number) + // toString() — debug-форма, raw представление собирается как "-". + assertEquals("v1-2a", "${c.epoch}-${c.number.toString(16)}") + } + + @Test + fun `fromString разбирает корректный курсор`() { + val c = Cursor.fromString("v2-1f4") + assertEquals("v2", c.epoch) + assertEquals(0x1f4uL, c.number) + } + + @Test + fun `round-trip через строковое представление`() { + val original = Cursor("v3", 0xdeadbeefuL) + val raw = "${original.epoch}-${original.number.toString(16)}" + val parsed = Cursor.fromString(raw) + assertEquals(original.epoch, parsed.epoch) + assertEquals(original.number, parsed.number) + } + + @Test + fun `next инкрементирует number сохраняя epoch`() { + val c = Cursor("v1", 5uL).next() + assertEquals("v1", c.epoch) + assertEquals(6uL, c.number) + } + + @Test + fun `next переходит через границу ULong`() { + val maxMinusOne = Cursor("v1", ULong.MAX_VALUE - 1uL).next() + assertEquals(ULong.MAX_VALUE, maxMinusOne.number) + } + + @Test + fun `compareTo внутри одной эпохи`() { + val a = Cursor("v1", 1uL) + val b = Cursor("v1", 2uL) + val c = Cursor("v1", 2uL) + assertEquals(true, a < b) + assertEquals(true, b > a) + assertEquals(0, b.compareTo(c)) + assertNotEquals(0, a.compareTo(b)) + } + + @Test + fun `compareTo между разными эпохами лексикографически по epoch`() { + // Реализация сравнивает epoch как строку, потом number как число. + // "v10" vs "v2": "v10" < "v2" лексикографически, потому что '1' < '2'. + // Это by design — нумерация эпох должна быть либо монотонной (v1, v2, v3), + // либо фиксированной длины (v01, v02). Внутри одного поколения журнала + // эпоха не меняется, так что лексикографическое сравнение эпох в проде не + // нужно — оно существует только для сортировки/отсечения. + val v2 = Cursor("v2", 999uL) + val v10 = Cursor("v10", 0uL) + assertEquals(true, v10 < v2) + } + + @Test + fun `compareTo с фиксированной длиной epoch монотонна`() { + val v01 = Cursor("v01", 0uL) + val v02 = Cursor("v02", 0uL) + val v10 = Cursor("v10", 0uL) + assertEquals(true, v01 < v02) + assertEquals(true, v02 < v10) + } + + @Test + fun `fromString бросает на пустую строку`() { + assertFailsWith { Cursor.fromString("") } + } + + @Test + fun `fromString бросает на строку без разделителя`() { + assertFailsWith { Cursor.fromString("noseparator") } + } + + @Test + fun `fromString бросает на строку с несколькими разделителями`() { + assertFailsWith { Cursor.fromString("v1-1-2") } + } + + @Test + fun `fromString бросает на невалидный hex в number`() { + assertFailsWith { Cursor.fromString("v1-zz") } + } + + @Test + fun `constructor бросает на пустую epoch`() { + assertFailsWith { Cursor("", 0uL) } + } + + @Test + fun `constructor бросает на epoch с разделителем`() { + assertFailsWith { Cursor("v-1", 0uL) } + } + + @Test + fun `number в верхнем регистре hex при fromString`() { + // fromString использует toULong(16), который case-insensitive. + val c = Cursor.fromString("v1-FF") + assertEquals(255uL, c.number) + } +} \ No newline at end of file diff --git a/sync-core/src/commonTest/kotlin/pw/binom/agentik/sync/test/EditDeleteTest.kt b/sync-core/src/commonTest/kotlin/pw/binom/agentik/sync/test/EditDeleteTest.kt new file mode 100644 index 0000000..f3bb1cf --- /dev/null +++ b/sync-core/src/commonTest/kotlin/pw/binom/agentik/sync/test/EditDeleteTest.kt @@ -0,0 +1,114 @@ +package pw.binom.agentik.sync.test + +import pw.binom.agentik.sync.ChatId +import pw.binom.agentik.sync.MessageId +import pw.binom.agentik.sync.test.memory.InMemorySyncBundle +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * Проверки §7.1 и §7.2 спеки: редактирование и удаление сообщений и чатов. + * + * Главные проверяемые инварианты: + * - Edited заменяет content «на месте», last_cursor обновляется. + * - Deleted физически удаляет строку (см. §7.2). + * - replace при resync подхватывает новый контент / отсутствующие строки. + */ +class EditDeleteTest { + + @Test + fun `edit заменяет content и обновляет lastCursor`() = kotlinx.coroutines.runBlocking { + val b = InMemorySyncBundle.create() + val created = b.server.append(samples.chatCreated(0)) + val appended = b.server.append(samples.messageAppended(0, 0, content = "v1")) + val edited = b.server.append(samples.messageEdited(0, 0, newContent = "v2")) + b.engine.sync() + val msgs = b.clientReadState().messages[ChatId("chat0")].orEmpty() + assertEquals(1, msgs.size) + assertEquals("v2", msgs.single().content) + assertEquals(edited.cursor, msgs.single().lastCursor) + // lastCursor строки > cursor appended. + assertTrue(msgs.single().lastCursor > appended.cursor) + // created event's cursor меньше appended, edited больше. + assertTrue(created.cursor < appended.cursor) + assertTrue(edited.cursor > appended.cursor) + b.close() + } + + @Test + fun `delete физически убирает строку`() = kotlinx.coroutines.runBlocking { + val b = InMemorySyncBundle.create() + b.server.append(samples.chatCreated(0)) + b.server.append(samples.messageAppended(0, 0)) + b.server.append(samples.messageAppended(0, 1)) + b.server.append(samples.messageDeleted(0, 1)) + b.engine.sync() + val msgs = b.clientReadState().messages[ChatId("chat0")].orEmpty() + assertEquals(1, msgs.size) + assertEquals(MessageId("msg-0-0"), msgs.single().id) + b.close() + } + + @Test + fun `resync после edit подхватывает новый content из снапшота`() = kotlinx.coroutines.runBlocking { + val b = InMemorySyncBundle.create() + b.server.append(samples.chatCreated(0)) + b.server.append(samples.messageAppended(0, 0, content = "v1")) + b.engine.sync() + // Edit на сервере. + b.server.append(samples.messageEdited(0, 0, newContent = "v2-final")) + b.compactServer(upTo = b.server.lastCursor()) + // Sync с clientLastCursor=1 (чат создан) → cursor_expired → replace. + b.engine.sync() + val msgs = b.clientReadState().messages[ChatId("chat0")].orEmpty() + assertEquals("v2-final", msgs.single().content) + b.close() + } + + @Test + fun `resync после delete убирает строку через снапшот`() = kotlinx.coroutines.runBlocking { + val b = InMemorySyncBundle.create() + b.server.append(samples.chatCreated(0)) + b.server.append(samples.messageAppended(0, 0)) + b.server.append(samples.messageAppended(0, 1)) + b.engine.sync() + b.server.append(samples.messageDeleted(0, 1)) + b.compactServer(upTo = b.server.lastCursor()) + b.engine.sync() + val msgs = b.clientReadState().messages[ChatId("chat0")].orEmpty() + assertEquals(1, msgs.size) + b.close() + } + + @Test + fun `chat deleted физически удаляет чат из state`() = kotlinx.coroutines.runBlocking { + // §7.2 + §10.1 спеки: «Не делать tombstones, если не нужно показывать + // удалено. Физическое удаление + replace при resync решают всё.» + val b = InMemorySyncBundle.create() + b.server.append(samples.chatCreated(0)) + b.server.append(samples.chatDeleted(0)) + b.engine.sync() + val chat = b.clientReadState().chats[ChatId("chat0")] + assertEquals(null, chat, "чат должен быть физически удалён") + b.close() + } + + @Test + fun `edit в середине диалога не теряет соседние сообщения`() = kotlinx.coroutines.runBlocking { + val b = InMemorySyncBundle.create() + b.server.append(samples.chatCreated(0)) + b.server.append(samples.messageAppended(0, 0, content = "a")) + b.server.append(samples.messageAppended(0, 1, content = "b")) + b.server.append(samples.messageAppended(0, 2, content = "c")) + b.server.append(samples.messageEdited(0, 1, newContent = "B!")) + b.engine.sync() + val msgs = b.clientReadState().messages[ChatId("chat0")].orEmpty() + assertEquals(3, msgs.size) + assertEquals("a", msgs[0].content) + assertEquals("B!", msgs[1].content) + assertEquals("c", msgs[2].content) + b.close() + } +} \ No newline at end of file diff --git a/sync-core/src/commonTest/kotlin/pw/binom/agentik/sync/test/EpochMismatchTest.kt b/sync-core/src/commonTest/kotlin/pw/binom/agentik/sync/test/EpochMismatchTest.kt new file mode 100644 index 0000000..3844661 --- /dev/null +++ b/sync-core/src/commonTest/kotlin/pw/binom/agentik/sync/test/EpochMismatchTest.kt @@ -0,0 +1,142 @@ +package pw.binom.agentik.sync.test + +import pw.binom.agentik.sync.Cursor +import pw.binom.agentik.sync.CursorExpiredReason +import pw.binom.agentik.sync.UpdatesResponse +import pw.binom.agentik.sync.test.memory.InMemorySyncBundle +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * Проверки §6.3 спеки: поведение при смене эпохи (epoch mismatch) и при + * протухшем по той же эпохе курсоре (cursor too old). В обоих случаях + * клиент обязан сделать полный resync (replaceState). + * + * - **WRONG_EPOCH** — клиент прислал курсор из другой эпохи (например, + * сервер был wiped или восстановлен из бэкапа). Сервер его отвергает + * с `reason = WRONG_EPOCH`. + * - **TOO_OLD** — клиент прислал курсор из той же эпохи, но ниже + * `min_available_cursor` (после compact). Сервер отвергает с + * `reason = TOO_OLD`. + * + * И там, и там клиент делает replaceState и начинает синхронизацию + * заново. Логически реакция одинаковая, но причина разная — отделяем + * для диагностики. + */ +class EpochMismatchTest { + + @Test + fun `WRONG_EPOCH - клиент со старой эпохой получает CursorExpired reason WRONG_EPOCH`() = kotlinx.coroutines.runBlocking { + val b = InMemorySyncBundle.create() + b.server.append(samples.chatCreated(0)) + b.server.append(samples.messageAppended(0, 0, content = "v1")) + b.engine.sync() + val clientCursorBefore = b.client.syncState().cursor + val clientEpochBefore = clientCursorBefore.epoch + + // Сервер wipe'ает журнал и начинает новую эпоху. + val newCursor = b.serverBeginNewEpoch(newEpoch = "v2") + assertEquals("v2", newCursor.epoch) + assertEquals(0uL, newCursor.number) + + // Клиент по-прежнему сидит в старой эпохе. Сервер ему отвечает. + val resp = b.transport.fetchUpdates(Cursor(clientEpochBefore, 100uL), 100) + assertTrue(resp is UpdatesResponse.CursorExpired) + val exp = resp as UpdatesResponse.CursorExpired + assertEquals(CursorExpiredReason.WRONG_EPOCH, exp.reason) + // Сервер подсказывает, в какой эпохе теперь живёт. + assertEquals("v2", exp.current.epoch) + + b.close() + } + + @Test + fun `TOO_OLD - клиент с тем же epoch но ниже min_available_cursor получает CursorExpired reason TOO_OLD`() = kotlinx.coroutines.runBlocking { + val b = InMemorySyncBundle.create() + b.server.append(samples.chatCreated(0)) + b.server.append(samples.messageAppended(0, 0)) + b.server.append(samples.messageAppended(0, 1)) + b.engine.sync() + + // Сервер compact'ит все события, клиентский курсор становится ниже floor. + b.compactServer(upTo = b.serverLastCursor()) + val resp = b.transport.fetchUpdates(Cursor.ZERO, 100) + assertTrue(resp is UpdatesResponse.CursorExpired) + val exp = resp as UpdatesResponse.CursorExpired + assertEquals(CursorExpiredReason.TOO_OLD, exp.reason) + // Эпоха совпадает. + assertEquals(b.client.syncState().cursor.epoch, exp.current.epoch) + + b.close() + } + + @Test + fun `sync после WRONG_EPOCH автоматически делает replaceState и подхватывает новое состояние`() = kotlinx.coroutines.runBlocking { + val b = InMemorySyncBundle.create() + b.server.append(samples.chatCreated(0)) + b.server.append(samples.messageAppended(0, 0, content = "old")) + b.engine.sync() + // Сервер wipe'ает журнал. Клиент по-прежнему сидит в старой эпохе. + b.serverBeginNewEpoch("v2") + b.server.append(samples.chatCreated(1)) + b.server.append(samples.messageAppended(1, 0, content = "fresh")) + + // Sync видит WRONG_EPOCH → replaceState. + val report = b.engine.sync() + assertTrue(report.resolvedCursorGap, "sync должен был резолвить gap") + + // Клиент в новой эпохе, не имеет старого чата, имеет новый. + assertEquals("v2", b.client.syncState().cursor.epoch) + val state = b.clientReadState() + assertEquals(null, state.chats[pw.binom.agentik.sync.ChatId("chat0")]) + assertEquals("fresh", state.messages[pw.binom.agentik.sync.ChatId("chat1")]?.single()?.content) + b.close() + } + + @Test + fun `sync после TOO_OLD тоже делает replaceState`() = kotlinx.coroutines.runBlocking { + val b = InMemorySyncBundle.create() + b.server.append(samples.chatCreated(0)) + b.server.append(samples.messageAppended(0, 0)) + b.engine.sync() + b.server.append(samples.messageAppended(0, 1, content = "still-cached")) + b.compactServer(upTo = b.serverLastCursor()) + + val report = b.engine.sync() + assertTrue(report.resolvedCursorGap) + // После replaceState клиент имеет полный снапшот — оба сообщения. + val msgs = b.clientReadState().messages[pw.binom.agentik.sync.ChatId("chat0")].orEmpty() + assertTrue(msgs.any { it.content == "still-cached" }, "новое сообщение должно быть в снапшоте") + b.close() + } + + @Test + fun `beginNewEpoch сбрасывает seq counter - новые события начинают от 1`() = kotlinx.coroutines.runBlocking { + val b = InMemorySyncBundle.create() + for (i in 0..10) b.server.append(samples.messageAppended(0, i)) + val before = b.serverLastCursor() + assertTrue(before.number > 10uL, "до wipe курсор должен быть больше 10") + + b.serverBeginNewEpoch("v2") + b.server.append(samples.chatCreated(0)) + val after = b.serverLastCursor() + // После wipe seq начинается заново: новое событие = 1 (а не 12). + assertEquals("v2", after.epoch) + assertEquals(1uL, after.number) + b.close() + } + + @Test + fun `epoch нужен для проверки stale-ness - курсор из другой эпохи не сравнивается через compareTo на «свежесть»`() { + // Документируем семантику: lexCompareTo для разных эпох упорядочит, + // но isAfter/isBefore возвращает null. Это by design. + val a = Cursor("v1", 100uL) + val b2 = Cursor("v2", 1uL) + // Lexicographic — лексикографически по строке эпохи: "v1" < "v2". + assertTrue(a < b2, "v1 лексикографически меньше v2") + // isAfter возвращает null — семантически курсоры несравнимы. + assertEquals(null, a.isAfter(b2)) + assertEquals(null, b2.isAfter(a)) + } +} \ No newline at end of file diff --git a/sync-core/src/commonTest/kotlin/pw/binom/agentik/sync/test/IdempotencyTest.kt b/sync-core/src/commonTest/kotlin/pw/binom/agentik/sync/test/IdempotencyTest.kt new file mode 100644 index 0000000..f44824f --- /dev/null +++ b/sync-core/src/commonTest/kotlin/pw/binom/agentik/sync/test/IdempotencyTest.kt @@ -0,0 +1,91 @@ +package pw.binom.agentik.sync.test + +import pw.binom.agentik.sync.Cursor +import pw.binom.agentik.sync.test.memory.InMemorySyncBundle +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * Проверки §8.1 спеки — идемпотентность по `eventId`. + */ +class IdempotencyTest { + + @Test + fun `повторный append с тем же eventId возвращает тот же cursor`() = kotlinx.coroutines.runBlocking { + val b = InMemorySyncBundle.create() + val e = samples.chatCreated(0) + val first = b.server.append(e) + val second = b.server.append(e) + assertEquals(first.cursor, second.cursor) + b.close() + } + + @Test + fun `повторный applyRemote с тем же eventId — no-op`() = kotlinx.coroutines.runBlocking { + val b = InMemorySyncBundle.create() + b.server.append(samples.chatCreated(0)) + b.server.append(samples.messageAppended(0, 0)) + b.engine.sync() + val snapshot = b.clientReadState() + val msgCountBefore = snapshot.messages.values.sumOf { it.size } + + // Применяем первое событие ещё раз — клиентский state не должен меняться. + val first = b.server.readUpdates(Cursor.ZERO, 100).first() + b.client.applyRemote(first) + val msgCountAfter = b.clientReadState().messages.values.sumOf { it.size } + assertEquals(msgCountBefore, msgCountAfter) + b.close() + } + + @Test + fun `postPending c дубликатом eventId принимает оба - но без дублей строк`() = kotlinx.coroutines.runBlocking { + val b = InMemorySyncBundle.create() + val userMsg = samples.messageAppended(0, 0, content = "hello") + + // Клиент создаёт pending (локально). + b.client.enqueuePending(userMsg, someInstant) + val pendingList = b.client.pendingEvents() + assertEquals(1, pendingList.size) + + // Сервер уже знает это сообщение (например, другое устройство отправило). + b.server.append(userMsg) + + // postPending должен принять (идемпотентно), строка одна. + val results = b.transport.postPending(pendingList) + assertEquals(1, results.size) + assertTrue(results.first() is pw.binom.agentik.sync.PostResult.Accepted) + b.client.markPendingAsSent( + pendingList.first().localId, + (results.first() as pw.binom.agentik.sync.PostResult.Accepted).cursor, + ) + // Клиент видит только одно сообщение. + val msgs = b.clientReadState().messages.values.flatten() + assertEquals(1, msgs.count { it.content == "hello" }) + b.close() + } + + @Test + fun `cursor монотонен после повторного append`() = kotlinx.coroutines.runBlocking { + val b = InMemorySyncBundle.create() + b.server.append(samples.chatCreated(0)) + val last1 = b.server.lastCursor() + // Повторный append с тем же eventId. + b.server.append(samples.chatCreated(0)) + assertEquals(last1, b.server.lastCursor(), "повторный append не двигает cursor") + // Новый eventId — сдвигает. + b.server.append(samples.messageAppended(0, 0)) + assertEquals(last1.next(), b.server.lastCursor()) + b.close() + } + + @Test + fun `updates возвращает одно и то же событие при повторных вызовах`() = kotlinx.coroutines.runBlocking { + val b = InMemorySyncBundle.create() + b.server.append(samples.chatCreated(0)) + val first = b.transport.fetchUpdates(Cursor.ZERO, 100) + val second = b.transport.fetchUpdates(Cursor.ZERO, 100) + assertEquals(first, second) + b.close() + } +} \ No newline at end of file diff --git a/sync-core/src/commonTest/kotlin/pw/binom/agentik/sync/test/InvariantsTest.kt b/sync-core/src/commonTest/kotlin/pw/binom/agentik/sync/test/InvariantsTest.kt new file mode 100644 index 0000000..c76e353 --- /dev/null +++ b/sync-core/src/commonTest/kotlin/pw/binom/agentik/sync/test/InvariantsTest.kt @@ -0,0 +1,166 @@ +package pw.binom.agentik.sync.test + +import pw.binom.agentik.sync.ChatId +import pw.binom.agentik.sync.ChatState +import pw.binom.agentik.sync.Cursor +import pw.binom.agentik.sync.DomainEvent +import pw.binom.agentik.sync.StateSnapshot +import pw.binom.agentik.sync.UpdatesResponse +import pw.binom.agentik.sync.test.memory.InMemorySyncBundle +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +/** + * Проверяет **семь инвариантов** из §2 спеки: + * 1. cursor монотонно возрастает (нет дыр, нет сбросов). + * 2. Журнал append-only (события не изменяются, не удаляются кроме как через compact). + * 3. Материализация консистентна журналу: apply(journal[0..N]) == SELECT state WHERE last_cursor <= N. + * 4. События самодостаточны (несут полный payload). + * 5. Full resync = replace (не merge). + * 6. Клиент читает только локальную БД (после sync clientReadState() == serverReadState()). + * 7. Компакция не удаляет события, нужные активным клиентам (cursor_expired → resync). + */ +class InvariantsTest { + + private suspend fun withBundle(block: suspend (SyncBundleFacade) -> Unit) { + val b = InMemorySyncBundle.create() + try { + block(SyncBundleFacade(b)) + } finally { + b.close() + } + } + + /** Лёгкая обёртка — выносим engine.sync() чтобы не дублировать. */ + class SyncBundleFacade(val b: pw.binom.agentik.sync.testing.SyncBundle) { + suspend fun sync() = b.engine.sync() + } + + // ─── Invariant 1: cursor монотонен ────────────────────────────────────── + + @Test + fun `1 cursor монотонно возрастает на append`() = kotlinx.coroutines.runBlocking { + withBundle { f -> + val s1 = f.b.server.append(samples.chatCreated(0)) + val s2 = f.b.server.append(samples.messageAppended(0, 0)) + val s3 = f.b.server.append(samples.messageAppended(0, 1)) + assertEquals(s1.cursor.next(), s2.cursor) + assertEquals(s2.cursor.next(), s3.cursor) + } + } + + @Test + fun `1 cursor number начинается с 1 на свежем журнале`() = kotlinx.coroutines.runBlocking { + withBundle { f -> + assertEquals(Cursor.ZERO, f.b.server.lastCursor()) + val s1 = f.b.server.append(samples.chatCreated(0)) + assertEquals(1uL, s1.cursor.number) + } + } + + // ─── Invariant 2: append-only ─────────────────────────────────────────── + + @Test + fun `2 события не изменяются после append`() = kotlinx.coroutines.runBlocking { + withBundle { f -> + val e = f.b.server.append(samples.chatCreated(0)) + val again = f.b.server.readUpdates(Cursor.ZERO, 100).single() + assertEquals(e, again) + } + } + + // ─── Invariant 3: materialization consistent with journal ──────────────── + + @Test + fun `3 материализация сервера консистентна журналу`() = kotlinx.coroutines.runBlocking { + withBundle { f -> + val e1 = f.b.server.append(samples.chatCreated(0)) + val e2 = f.b.server.append(samples.messageAppended(0, 0)) + val e3 = f.b.server.append(samples.messageAppended(0, 1)) + val state = f.b.serverReadState() + assertTrue(state.chats.isNotEmpty(), "чат должен быть в state") + assertEquals(2, state.messages[ChatId("chat0")]?.size, "два сообщения в state") + assertEquals(e3.cursor, state.lastCursor) + assertTrue(e1.cursor <= state.lastCursor) + assertTrue(e2.cursor <= state.lastCursor) + } + } + + // ─── Invariant 4: events self-contained ───────────────────────────────── + + @Test + fun `4 событие несёт полный payload - не дельту`() = kotlinx.coroutines.runBlocking { + withBundle { f -> + val created = DomainEvent.ChatCreated( + eventId = "e-create-0", + chatId = ChatId("chat-X"), + title = "Полное название", + createdAt = someInstant, + ) + val logged = f.b.server.append(created) + val read = f.b.server.readUpdates(Cursor.ZERO, 100).single() + assertEquals("Полное название", (read.payload as DomainEvent.ChatCreated).title) + assertEquals(ChatId("chat-X"), (read.payload as DomainEvent.ChatCreated).chatId) + assertEquals(logged.cursor, read.cursor) + } + } + + // ─── Invariant 5: replace, не merge ───────────────────────────────────── + + @Test + fun `5 replaceState полностью заменяет state - не мержит`() = kotlinx.coroutines.runBlocking { + withBundle { f -> + f.b.server.append(samples.chatCreated(0)) + f.b.server.append(samples.chatCreated(1)) + f.b.server.append(samples.messageAppended(0, 0)) + f.sync() + assertEquals(2, f.b.clientReadState().chats.size) + f.b.server.append(samples.chatDeleted(0)) + // Стираем клиентский state вручную. + f.b.client.replaceState(StateSnapshot(ChatState())) + // Сервер делает compact → cursor_expired → replace. + f.b.compactServer(upTo = f.b.serverLastCursor()) + f.sync() + val chats = f.b.clientReadState().chats + assertEquals(1, chats.size, "после replace лишний чат должен исчезнуть") + assertNotNull(chats[ChatId("chat1")]) + assertEquals(null, chats[ChatId("chat0")], "удалённый чат не должен быть в state") + } + } + + // ─── Invariant 6: client state == server state после sync ─────────────── + + @Test + fun `6 clientReadState совпадает с serverReadState после sync`() = kotlinx.coroutines.runBlocking { + withBundle { f -> + f.b.server.append(samples.chatCreated(0)) + f.b.server.append(samples.messageAppended(0, 0)) + f.b.server.append(samples.chatCreated(1)) + f.b.server.append(samples.messageAppended(1, 0)) + f.sync() + assertEquals(f.b.serverReadState(), f.b.clientReadState()) + } + } + + // ─── Invariant 7: cursor_expired → full resync ─────────────────────────── + + @Test + fun `7 compact делает cursor протухшим - sync делает replace`() = kotlinx.coroutines.runBlocking { + withBundle { f -> + f.b.server.append(samples.chatCreated(0)) + f.b.server.append(samples.messageAppended(0, 0)) + f.sync() + val clientCursor = f.b.clientLastCursor() + f.b.server.append(samples.messageAppended(0, 1)) + f.b.server.append(samples.messageAppended(0, 2)) + f.b.compactServer(upTo = clientCursor) + val resp = f.b.transport.fetchUpdates(clientCursor, 100) + assertTrue(resp is UpdatesResponse.CursorExpired, "должен быть CursorExpired") + val report = f.b.engine.sync() + assertTrue(report.resolvedCursorGap, "sync должен resolve cursor gap") + assertEquals(3, f.b.clientReadState().messages[ChatId("chat0")]?.size) + } + } +} \ No newline at end of file diff --git a/sync-core/src/commonTest/kotlin/pw/binom/agentik/sync/test/OfflineWriteTest.kt b/sync-core/src/commonTest/kotlin/pw/binom/agentik/sync/test/OfflineWriteTest.kt new file mode 100644 index 0000000..9bed8c1 --- /dev/null +++ b/sync-core/src/commonTest/kotlin/pw/binom/agentik/sync/test/OfflineWriteTest.kt @@ -0,0 +1,106 @@ +package pw.binom.agentik.sync.test + +import pw.binom.agentik.sync.ChatId +import pw.binom.agentik.sync.LocalId +import pw.binom.agentik.sync.PendingStatus +import pw.binom.agentik.sync.PostResult +import pw.binom.agentik.sync.test.memory.InMemorySyncBundle +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * Проверки §3.2 (PendingEvent), §5.3 (userSendsMessage), §5.4 (localAssistant), + * §8.4 (офлайн-запись). + */ +class OfflineWriteTest { + + @Test + fun `client enqueuePending оптимистично применяет событие`() = kotlinx.coroutines.runBlocking { + val b = InMemorySyncBundle.create() + b.server.append(samples.chatCreated(0)) + val pe = b.client.enqueuePending(samples.messageAppended(0, 0, content = "hi"), someInstant) + val msgs = b.clientReadState().messages[ChatId("chat0")].orEmpty() + assertEquals(1, msgs.size) + assertEquals("hi", msgs.single().content) + assertEquals(PendingStatus.PENDING, pe.status) + b.close() + } + + @Test + fun `sync отправляет pending на сервер и обновляет статус`() = kotlinx.coroutines.runBlocking { + val b = InMemorySyncBundle.create() + b.server.append(samples.chatCreated(0)) + b.client.enqueuePending(samples.messageAppended(0, 0, content = "hi"), someInstant) + val report = b.engine.sync() + assertEquals(1, report.postedPending) + assertEquals(0, report.failedPending) + // Сервер тоже видит это сообщение. + val serverMsgs = b.serverReadState().messages[ChatId("chat0")].orEmpty() + assertEquals(1, serverMsgs.size) + assertEquals("hi", serverMsgs.single().content) + b.close() + } + + @Test + fun `сначала client пишёт офлайн - потом sync - потом новое сообщение и опять sync`() = kotlinx.coroutines.runBlocking { + val b = InMemorySyncBundle.create() + b.server.append(samples.chatCreated(0)) + b.client.enqueuePending(samples.messageAppended(0, 0, content = "offline-1"), someInstant) + b.engine.sync() + b.client.enqueuePending(samples.messageAppended(0, 1, content = "online-1"), someInstant) + b.engine.sync() + val serverMsgs = b.serverReadState().messages[ChatId("chat0")].orEmpty() + assertEquals(2, serverMsgs.size) + b.close() + } + + @Test + fun `PendingEvent остаётся после replaceState`() = kotlinx.coroutines.runBlocking { + val b = InMemorySyncBundle.create() + b.server.append(samples.chatCreated(0)) + b.client.enqueuePending(samples.messageAppended(0, 0, content = "keep-me"), someInstant) + b.client.replaceState(pw.binom.agentik.sync.StateSnapshot(pw.binom.agentik.sync.ChatState())) + val pending = b.client.pendingEvents() + assertEquals(1, pending.size) + assertEquals(PendingStatus.PENDING, pending.single().status) + val report = b.engine.sync() + assertEquals(1, report.postedPending) + val serverMsgs = b.serverReadState().messages[ChatId("chat0")].orEmpty() + assertEquals(1, serverMsgs.size) + assertEquals("keep-me", serverMsgs.single().content) + b.close() + } + + @Test + fun `markPendingAsFailed переводит pending в FAILED`() = kotlinx.coroutines.runBlocking { + val b = InMemorySyncBundle.create() + b.server.append(samples.chatCreated(0)) + val pe = b.client.enqueuePending(samples.messageAppended(0, 0, content = "x"), someInstant) + b.client.markPendingAsFailed(pe.localId) + val pending = b.client.pendingEvents() + // markPendingAsFailed — теперь pending в FAILED. + // pendingEvents() отдаёт только PENDING — поэтому проверяем через allPending(). + val all = (b.client as pw.binom.agentik.sync.memory.InMemoryEventLog).allPending() + assertEquals(PendingStatus.FAILED, all.single().status) + b.close() + } + + @Test + fun `postPending возвращает Rejected если сервер отверг`() = kotlinx.coroutines.runBlocking { + val b = InMemorySyncBundle.create() + b.server.append(samples.chatCreated(0)) + // Сначала кладём pending в локальную очередь. + b.client.enqueuePending(samples.messageAppended(0, 0, content = "x"), someInstant) + // Симулируем ответ сервера — rejected. + val results: List = listOf(PostResult.Rejected(localId = LocalId("evt-ma-0-0"), reason = "validation failed")) + for (r in results) { + when (r) { + is PostResult.Accepted -> b.client.markPendingAsSent(r.localId, r.cursor) + is PostResult.Rejected -> b.client.markPendingAsFailed(r.localId) + } + } + val all = (b.client as pw.binom.agentik.sync.memory.InMemoryEventLog).allPending() + assertEquals(PendingStatus.FAILED, all.single().status) + b.close() + } +} \ No newline at end of file diff --git a/sync-core/src/commonTest/kotlin/pw/binom/agentik/sync/test/Samples.kt b/sync-core/src/commonTest/kotlin/pw/binom/agentik/sync/test/Samples.kt new file mode 100644 index 0000000..7ec1462 --- /dev/null +++ b/sync-core/src/commonTest/kotlin/pw/binom/agentik/sync/test/Samples.kt @@ -0,0 +1,63 @@ +package pw.binom.agentik.sync.test + +import pw.binom.agentik.sync.ChatId +import pw.binom.agentik.sync.DomainEvent +import pw.binom.agentik.sync.MessageId +import kotlin.time.Instant + +/** + * Утилиты для тестов: удобные конструкторы событий с детерминированными + * `eventId` (чтобы тесты могли ссылаться на них в проверках). + */ +object samples { + + private fun eid(tag: String, n: Int): String = "evt-$tag-$n" + + fun chatCreated(n: Int): DomainEvent.ChatCreated = DomainEvent.ChatCreated( + eventId = eid("cc", n), + chatId = ChatId("chat$n"), + title = "Чат #$n", + createdAt = someInstant, + ) + + fun chatRenamed(n: Int, newTitle: String = "Чат #$n — переименован"): DomainEvent.ChatRenamed = + DomainEvent.ChatRenamed( + eventId = eid("cr", n), + chatId = ChatId("chat$n"), + newTitle = newTitle, + ) + + fun chatDeleted(n: Int): DomainEvent.ChatDeleted = DomainEvent.ChatDeleted( + eventId = eid("cd", n), + chatId = ChatId("chat$n"), + ) + + fun messageAppended(chatN: Int, msgN: Int, content: String = "msg-$chatN-$msgN"): DomainEvent.MessageAppended = + DomainEvent.MessageAppended( + eventId = eid("ma-$chatN", msgN), + messageId = MessageId("msg-$chatN-$msgN"), + chatId = ChatId("chat$chatN"), + role = if (msgN % 2 == 0) pw.binom.agentik.sync.MessageRole.USER else pw.binom.agentik.sync.MessageRole.ASSISTANT, + content = content, + parentId = null, + createdAt = someInstant, + ) + + fun messageEdited(chatN: Int, msgN: Int, newContent: String): DomainEvent.MessageEdited = + DomainEvent.MessageEdited( + eventId = eid("me-$chatN", msgN), + messageId = MessageId("msg-$chatN-$msgN"), + chatId = ChatId("chat$chatN"), + newContent = newContent, + ) + + fun messageDeleted(chatN: Int, msgN: Int): DomainEvent.MessageDeleted = + DomainEvent.MessageDeleted( + eventId = eid("md-$chatN", msgN), + messageId = MessageId("msg-$chatN-$msgN"), + chatId = ChatId("chat$chatN"), + ) +} + +/** Фиксированный момент времени — тесты не зависят от часов. */ +val someInstant: Instant = Instant.parse("2026-10-01T00:00:00Z") \ No newline at end of file diff --git a/sync-core/src/commonTest/kotlin/pw/binom/agentik/sync/test/memory/InMemorySyncBundle.kt b/sync-core/src/commonTest/kotlin/pw/binom/agentik/sync/test/memory/InMemorySyncBundle.kt new file mode 100644 index 0000000..da25739 --- /dev/null +++ b/sync-core/src/commonTest/kotlin/pw/binom/agentik/sync/test/memory/InMemorySyncBundle.kt @@ -0,0 +1,64 @@ +package pw.binom.agentik.sync.test.memory + +import pw.binom.agentik.sync.CompactionState +import pw.binom.agentik.sync.Cursor +import pw.binom.agentik.sync.EventLogReplica +import pw.binom.agentik.sync.EventLogWriter +import pw.binom.agentik.sync.StateStore +import pw.binom.agentik.sync.SyncEngine +import pw.binom.agentik.sync.SyncTransport +import pw.binom.agentik.sync.memory.InMemoryEventLog +import pw.binom.agentik.sync.memory.InProcessTransport +import pw.binom.agentik.sync.testing.SyncBundle + +/** + * Бэкенд для тестов на чистой памяти. Серверная и клиентская сторона — + * разные [InMemoryEventLog] (writer-роль и replica-роль), объединённые + * общим [InProcessTransport] (имитирует HTTP/WS). + */ +class InMemorySyncBundle private constructor( + private val serverLog: InMemoryEventLog, + private val clientLog: InMemoryEventLog, + private val transportImpl: InProcessTransport, +) : SyncBundle() { + + override val server: EventLogWriter = serverLog + override val serverState: StateStore = serverLog + override val client: EventLogReplica = clientLog + override val clientState: StateStore = clientLog + override val transport: SyncTransport = transportImpl + override val engine: SyncEngine = SyncEngine(replica = clientLog, transport = transportImpl) + + override suspend fun compactionState(): CompactionState = serverLog.compaction() + + override suspend fun compactServer(upTo: Cursor) { + serverLog.compact(upTo) + } + + override suspend fun serverBeginNewEpoch(newEpoch: String): Cursor = + serverLog.beginNewEpoch(newEpoch) + + /** Отдельно сменить эпоху на клиенте (для тестов сценария «клиент уже + * синхронизировался, а потом сервер wipe'нул»). */ + suspend fun clientBeginNewEpoch(newEpoch: String): Cursor = clientLog.beginNewEpoch(newEpoch) + + override fun close() { + serverLog.close() + clientLog.close() + transportImpl.close() + } + + companion object { + /** Свежий изолированный бандл. */ + fun create(): InMemorySyncBundle { + val server = InMemoryEventLog() + val client = InMemoryEventLog() + val transport = InProcessTransport( + server = server, + stateStore = server, + compactionState = { server.compaction() }, + ) + return InMemorySyncBundle(serverLog = server, clientLog = client, transportImpl = transport) + } + } +} \ No newline at end of file