Добавляет :sync-core — референсная реализация SYNC-SYSTEM.md.
Новый 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<S, E> — детерминированная свёртка событий в
снапшот 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<DomainEvent>.
- KsqliteSyncSpecTest (15) — все спец-тесты на ksqlite.
- KsqlitePersistenceTest — file-based persistence.
Существующий код (:outbox-api / :journal-api / :client / :server) не
трогаем — это чистая референсная реализация для последующей миграции.
This commit is contained in:
@@ -55,6 +55,7 @@ val moduleDescriptions: Map<String, String> = mapOf(
|
|||||||
"agentik-cli" to "agentik :agentik-cli — JVM CLI-клиент (JLine) к /agentik: REPL + slash-команды + стрим SSE.",
|
"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)."
|
// "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.",
|
"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)
|
rootProject.extra.set("moduleDescriptions", moduleDescriptions)
|
||||||
|
|
||||||
|
|||||||
@@ -146,3 +146,10 @@ include(":agent-api")
|
|||||||
// SkillMiningComponent, слушающий outbox (Event.ConversationClosing /
|
// SkillMiningComponent, слушающий outbox (Event.ConversationClosing /
|
||||||
// Event.CompactionTriggered) и добывающий новые скилы через SkillMiner.
|
// Event.CompactionTriggered) и добывающий новые скилы через SkillMiner.
|
||||||
include(":skill-mining")
|
include(":skill-mining")
|
||||||
|
// Референсная реализация спеки `SYNC-SYSTEM.md` — журнал событий с
|
||||||
|
// монотонным курсором + материализация + replace-resync, единый протокол
|
||||||
|
// для клиента и сервера. KMP с jvm/linuxX64/mingwX64 целями (ksqlite-
|
||||||
|
// ограничение). Тесты спеки в commonTest, интеграция с ksqlite — в jvmTest.
|
||||||
|
// Существующий код (outbox/journal/client/server) не трогаем; это чистый
|
||||||
|
// референс для последующей миграции.
|
||||||
|
include(":sync-core")
|
||||||
|
|||||||
@@ -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` останется референсом и песочницей для новых идей).
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<DomainEvent>,
|
||||||
|
) : Assistant {
|
||||||
|
override fun run(input: RunInput): Flow<DomainEvent> = flow {
|
||||||
|
flowBuilder(input).collect { e ->
|
||||||
|
emit(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Удобный конструктор для тестов: возвращает готовый [Flow] фиксированных
|
||||||
|
* событий. Не использовать в production — это test-fixture.
|
||||||
|
*/
|
||||||
|
fun scriptedAssistant(events: List<DomainEvent>): 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 спеки: «строго возрастает, без дыр»).
|
||||||
|
*
|
||||||
|
* Строковое представление — `"<epoch>-<number_hex>"`, например `"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)
|
||||||
|
|
||||||
|
/** Строковое представление `"<epoch>-<number_hex>"`. */
|
||||||
|
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]. Кодируется как
|
||||||
|
* примитивная строка формата `"<epoch>-<number_hex>"`.
|
||||||
|
*/
|
||||||
|
object CursorSerializer : KSerializer<Cursor> {
|
||||||
|
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())
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
@@ -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<ChatState> {
|
||||||
|
|
||||||
|
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 <K, V> Map<K, V>.update(key: K, block: (V) -> V): Map<K, V> {
|
||||||
|
val old = this[key] ?: return this
|
||||||
|
return this + (key to block(old))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Map<ChatId, List<Message>>.updateMessages(
|
||||||
|
chatId: ChatId,
|
||||||
|
block: (List<Message>) -> List<Message>,
|
||||||
|
): Map<ChatId, List<Message>> {
|
||||||
|
val old = this[chatId] ?: emptyList()
|
||||||
|
return this + (chatId to block(old))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Map<ChatId, List<Message>>.appendMessage(
|
||||||
|
chatId: ChatId,
|
||||||
|
message: Message,
|
||||||
|
): Map<ChatId, List<Message>> {
|
||||||
|
val old = this[chatId] ?: emptyList()
|
||||||
|
return this + (chatId to (old + message))
|
||||||
|
}
|
||||||
@@ -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<ChatId, Chat> = emptyMap(),
|
||||||
|
val messages: Map<ChatId, List<Message>> = 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
|
||||||
|
}
|
||||||
@@ -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)")
|
||||||
@@ -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?,
|
||||||
|
)
|
||||||
@@ -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<LoggedEvent>
|
||||||
|
|
||||||
|
/** Текущая вершина журнала. `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<PendingEvent>
|
||||||
|
|
||||||
|
/** Добавить локальное событие в очередь (статус 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<S> {
|
||||||
|
fun reduce(state: S, event: LoggedEvent): S
|
||||||
|
}
|
||||||
@@ -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<LoggedEvent> = 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,
|
||||||
|
)
|
||||||
@@ -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<PendingEvent>): List<PostResult>
|
||||||
|
|
||||||
|
fun subscribeLive(after: Cursor): Flow<LoggedEvent>
|
||||||
|
|
||||||
|
override fun close()
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed interface UpdatesResponse {
|
||||||
|
/** Успешный replay — пачка событий + индикатор «есть ещё» + текущий курсор. */
|
||||||
|
data class Ok(
|
||||||
|
val events: List<LoggedEvent>,
|
||||||
|
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<DomainEvent>`.
|
||||||
|
* - Каждое событие несёт **стабильный** `eventId` (для идемпотентности).
|
||||||
|
* - `Flow` завершается, когда ассистент закончил генерацию. Ошибка внутри
|
||||||
|
* потока пробрасывается вызывающему.
|
||||||
|
*/
|
||||||
|
fun interface Assistant {
|
||||||
|
fun run(input: RunInput): Flow<DomainEvent>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Параметры запуска ассистента (§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,
|
||||||
|
)
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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()}')")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<LoggedEvent> = MutableSharedFlow(
|
||||||
|
replay = 0,
|
||||||
|
extraBufferCapacity = 64,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Публичный read-only view. */
|
||||||
|
val appendFlow: SharedFlow<LoggedEvent> get() = appendChannel
|
||||||
|
|
||||||
|
private val appendListeners: MutableList<suspend (LoggedEvent) -> 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<LoggedEvent> = 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<LoggedEvent>()
|
||||||
|
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<String, ULong> {
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
+319
@@ -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<LoggedEvent> = 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<PendingEvent> = withContext(Dispatchers.Default) {
|
||||||
|
selectPendingStmt.reset()
|
||||||
|
selectPendingStmt.clearBindings()
|
||||||
|
selectPendingStmt.bindText(1, PendingStatus.PENDING.name)
|
||||||
|
val out = mutableListOf<PendingEvent>()
|
||||||
|
selectPendingStmt.executeQuery().use { rs ->
|
||||||
|
while (rs.next()) out.add(rs.toPending())
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Все pending-события (для тестов). */
|
||||||
|
suspend fun allPending(): List<PendingEvent> = withContext(Dispatchers.Default) {
|
||||||
|
selectAllPendingStmt.reset()
|
||||||
|
selectAllPendingStmt.clearBindings()
|
||||||
|
val out = mutableListOf<PendingEvent>()
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<ChatState> = 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<ChatId, Chat>()
|
||||||
|
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<ChatId, MutableList<Message>>()
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<ChatState> = DefaultChatReducer,
|
||||||
|
initialEpoch: String = Cursor.ZERO.epoch,
|
||||||
|
) : EventLogWriter, EventLogReplica, StateStore {
|
||||||
|
|
||||||
|
private val mutex = Mutex()
|
||||||
|
|
||||||
|
/** Журнал событий: append-only. */
|
||||||
|
private val events: MutableList<LoggedEvent> = mutableListOf()
|
||||||
|
/** Для идемпотентности applyRemote: уже применённые `eventId`. */
|
||||||
|
private val appliedEventIds: MutableSet<String> = mutableSetOf()
|
||||||
|
|
||||||
|
/** Материализованное состояние (обновляется атомарно с append/applyRemote). */
|
||||||
|
private var state: ChatState = ChatState()
|
||||||
|
|
||||||
|
/** Pending-event'ы. Стартуют со статусом PENDING → SENT/FAILED. */
|
||||||
|
private val pending: MutableMap<LocalId, PendingEvent> = mutableMapOf()
|
||||||
|
|
||||||
|
/** Подписчики на append (используется [InProcessTransport] для live-канала). */
|
||||||
|
internal val appendListeners: MutableList<suspend (LoggedEvent) -> 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<LoggedEvent> = 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<PendingEvent> = 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<PendingEvent> = mutex.withLock { pending.values.toList() }
|
||||||
|
|
||||||
|
/** Текущая эпоха (read-only). */
|
||||||
|
suspend fun epoch(): String = mutex.withLock { currentEpoch }
|
||||||
|
|
||||||
|
override fun close() {
|
||||||
|
// Ничего не делаем — in-memory.
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<LoggedEvent>(
|
||||||
|
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<PendingEvent>): List<PostResult> {
|
||||||
|
val out = ArrayList<PostResult>(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<LoggedEvent> {
|
||||||
|
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 <T> runBlocking(block: suspend () -> T): T =
|
||||||
|
kotlinx.coroutines.runBlocking { block() }
|
||||||
@@ -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)
|
||||||
|
// переопределяют.
|
||||||
|
}
|
||||||
|
}
|
||||||
+55
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<DomainEvent>()
|
||||||
|
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",
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 представление собирается как "<epoch>-<number.toString(16)>".
|
||||||
|
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<IllegalArgumentException> { Cursor.fromString("") }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `fromString бросает на строку без разделителя`() {
|
||||||
|
assertFailsWith<IllegalArgumentException> { Cursor.fromString("noseparator") }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `fromString бросает на строку с несколькими разделителями`() {
|
||||||
|
assertFailsWith<IllegalArgumentException> { Cursor.fromString("v1-1-2") }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `fromString бросает на невалидный hex в number`() {
|
||||||
|
assertFailsWith<IllegalArgumentException> { Cursor.fromString("v1-zz") }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `constructor бросает на пустую epoch`() {
|
||||||
|
assertFailsWith<IllegalArgumentException> { Cursor("", 0uL) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `constructor бросает на epoch с разделителем`() {
|
||||||
|
assertFailsWith<IllegalArgumentException> { Cursor("v-1", 0uL) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `number в верхнем регистре hex при fromString`() {
|
||||||
|
// fromString использует toULong(16), который case-insensitive.
|
||||||
|
val c = Cursor.fromString("v1-FF")
|
||||||
|
assertEquals(255uL, c.number)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<PostResult> = 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
+64
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user