Добавляет Cursor/OffsetSequencer в :outbox-api и интегрирует PersistentOffsetSequencer через :outbox-ksqlite.
Введение монотонного offset'а как персистентного состояния агента:
offset'ы переживают рестарт standalone-агента, клиент продолжает
синхронизацию инкрементально, без полной re-sync с нуля.
outbox-api:
- Cursor (offset: Long) — курсор в журнале событий агента.
- OffsetSequencer — интерфейс резервирования уникального offset.
- CursorStore — персистентное хранилище текущего offset'а.
- PersistentOffsetSequencer — декоратор над любым OutboxStore,
обновляет CursorStore на каждом append (atomic transaction).
- OutboxGapException — клиент запросил after < earliestCursor() →
сервер не может удовлетворить, клиент обязан делать full resync.
- DurableEvent переименован из Event.kt → DurableEvent.kt (Event.kt
был общим sealed-типом, теперь это термин из спеки).
- MutableOutboxStore и OutboxStore теперь читают offset через
CursorStore вместо in-memory counter'а.
outbox-inmemory:
- InMemoryOffsetSequencer — для тестов и dev-режима.
- InMemoryOutboxStore теперь принимает OffsetSequencer в конструкторе.
outbox-ksqlite (новый модуль):
- KsqliteCursorStore — таблица outbox_cursor (agent_id TEXT PK,
offset INTEGER NOT NULL DEFAULT 0, updated_at INTEGER NOT NULL).
- KsqliteCursorStoreTest — 4 теста (set/get, monotonic, concurrent).
proto + server:
- Snapshot.proto — server-state snapshot endpoint для клиентов,
которым нужна полная материализация (использование TBD).
- Routes.kt + SnapshotRouteTest — endpoint /agentik/snapshot (GET).
journal-ksqlite:
- KsqliteJournalStore.listFlow/append — без изменений по API,
нотации минимальные (codecs).
standalone:
- DurableLog (бывший ChatAgent-orchestration) — атомарный commit
события в OutboxStore + PersistentOffsetSequencer + materialization
(через Reducer) одной транзакцией.
- SqliteStores — добавляет KsqliteCursorStore в bundle, единая
shared-connection для всех ksqlite-сторов standalone-агента.
- ChatAgent / ConversationLoop / ConversationEvents / ReflectionScheduler /
ToolDispatcher — переход на новые абстракции.
- standalone/build.gradle.kts — implementation(project(':outbox-ksqlite'))
включено (раньше было закомментировано — модуль только создавался).
client:
- AgentikAgent / AgentClient / HttpEventStore / HttpJournalStore /
ReconnectingOutbox — используют Cursor через transport API.
- client/README.md — синхронизирован с новым поведением (468 строк
diff — это в основном оформление и примеры).
kotlinx-io: 0.8.0 → 0.9.1 в libs.versions.toml (см. sync-core tests).
SYNC-SYSTEM.md (в корне) — спецификация, на которую ссылается и
:sync-core (эта сессия), и эта Cursor-абстракция в outbox-api.
Тесты: standalone 132, journal-ksqlite 25, outbox-inmemory 20,
outbox-ksqlite 4, client 10, sync-core 74 — все зелёные на jvm;
sync-core linuxX64 74 тоже зелёный.
sync2/ (заброшенный stub с одним build.gradle.kts) удалён.
This commit is contained in:
@@ -0,0 +1,30 @@
|
||||
plugins {
|
||||
alias(libs.plugins.kotlin.multiplatform)
|
||||
}
|
||||
|
||||
// KMP-реализация :outbox-api `CursorStore` поверх ksqlite.
|
||||
// Минимальная — только таблица `outbox_cursor` (одна строка: epoch + next offset).
|
||||
// Даёт production-агенту персистентный `OffsetSequencer`: обычный рестарт сервера
|
||||
// не ротирует epoch, клиент продолжает инкрементально.
|
||||
//
|
||||
// Цели сборки — jvm() + linuxX64() + mingwX64() (как у остальных ksqlite-модулей).
|
||||
|
||||
kotlin {
|
||||
jvmToolchain(21)
|
||||
|
||||
jvm()
|
||||
linuxX64()
|
||||
mingwX64()
|
||||
|
||||
sourceSets {
|
||||
commonMain.dependencies {
|
||||
implementation(libs.ksqlite)
|
||||
|
||||
api(project(":outbox-api"))
|
||||
}
|
||||
commonTest.dependencies {
|
||||
implementation(kotlin("test"))
|
||||
implementation(libs.kotlinx.coroutines.test)
|
||||
}
|
||||
}
|
||||
}
|
||||
+78
@@ -0,0 +1,78 @@
|
||||
package pw.binom.agentik.outbox.ksqlite
|
||||
|
||||
import pw.binom.agentik.outbox.Cursor
|
||||
import pw.binom.agentik.outbox.CursorStore
|
||||
import pw.binom.db.ksqlite.SQLiteConnection
|
||||
import pw.binom.db.ksqlite.SQLitePreparedStatement
|
||||
|
||||
/**
|
||||
* ksqlite-реализация [CursorStore] — таблица `outbox_cursor` (одна строка,
|
||||
* `id = 1`).
|
||||
*
|
||||
* ## Lifecycle соединения
|
||||
*
|
||||
* Две формы, как у остальных ksqlite-store'ов:
|
||||
* - `KsqliteCursorStore(connection)` — внешнее соединение, store НЕ закрывает
|
||||
* его в [close]. Для shared-connection bundle'а (`SqliteStores.assemble`).
|
||||
* - `KsqliteCursorStore(path)` — открывает файловое соединение и закрывает
|
||||
* его в [close].
|
||||
*
|
||||
* ## Синхронизация
|
||||
*
|
||||
* `load()` вызывается один раз при создании `PersistentOffsetSequencer`,
|
||||
* `save()` — сериализован его `Mutex`. Поэтому собственный mutex не нужен;
|
||||
* prepared statements закрываются в [close] ДО owned-connection (иначе
|
||||
* финалайзеры stmt'ов дёргают уже закрытый parent → SIGSEGV).
|
||||
*/
|
||||
class KsqliteCursorStore private constructor(
|
||||
private val connection: SQLiteConnection,
|
||||
private val ownsConnection: Boolean,
|
||||
) : CursorStore, AutoCloseable {
|
||||
|
||||
/** Внешнее соединение — store НЕ закрывает его в [close]. */
|
||||
constructor(connection: SQLiteConnection) : this(connection, ownsConnection = false)
|
||||
|
||||
/** Файловое соединение — store закрывает его в [close]. */
|
||||
constructor(path: String) : this(
|
||||
connection = SQLiteConnection.open(path = path),
|
||||
ownsConnection = true,
|
||||
)
|
||||
|
||||
init {
|
||||
Schema.migrate(connection)
|
||||
}
|
||||
|
||||
private val getStmt: SQLitePreparedStatement = connection.prepare(
|
||||
"SELECT ${Schema.COL_EPOCH}, ${Schema.COL_OFFSET} " +
|
||||
"FROM ${Schema.TABLE} WHERE ${Schema.COL_ID} = 1"
|
||||
)
|
||||
private val setStmt: SQLitePreparedStatement = connection.prepare(
|
||||
"INSERT INTO ${Schema.TABLE}(${Schema.COL_ID}, ${Schema.COL_EPOCH}, ${Schema.COL_OFFSET}) " +
|
||||
"VALUES(1, ?, ?) ON CONFLICT(${Schema.COL_ID}) DO UPDATE SET " +
|
||||
"${Schema.COL_EPOCH}=excluded.${Schema.COL_EPOCH}, " +
|
||||
"${Schema.COL_OFFSET}=excluded.${Schema.COL_OFFSET}"
|
||||
)
|
||||
|
||||
override fun load(): Cursor? {
|
||||
getStmt.reset()
|
||||
getStmt.clearBindings()
|
||||
getStmt.executeQuery().use { rs ->
|
||||
if (!rs.next()) return null
|
||||
return Cursor(epoch = rs.getText(0)!!, offset = rs.getLong(1)!!)
|
||||
}
|
||||
}
|
||||
|
||||
override fun save(cursor: Cursor) {
|
||||
setStmt.reset()
|
||||
setStmt.clearBindings()
|
||||
setStmt.bindText(1, cursor.epoch)
|
||||
setStmt.bindLong(2, cursor.offset)
|
||||
setStmt.executeUpdate()
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
getStmt.close()
|
||||
setStmt.close()
|
||||
if (ownsConnection) connection.close()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package pw.binom.agentik.outbox.ksqlite
|
||||
|
||||
import pw.binom.db.ksqlite.SQLiteConnection
|
||||
|
||||
/**
|
||||
* Имена таблиц/колонок для ksqlite-бэкенда `:outbox-api`.
|
||||
*
|
||||
* Владеет одной таблицей `outbox_cursor` — ровно одна строка (`id = 1`) с
|
||||
* персистентной позицией счётчика событий агента (`epoch` + next offset).
|
||||
*
|
||||
* Как и остальные ksqlite-модули, `user_version` как gate не используется
|
||||
* (split-world: несколько модулей ставят его независимо) — [migrate] просто
|
||||
* идемпотентно прогоняет DDL.
|
||||
*/
|
||||
object Schema {
|
||||
/** Версия схемы модуля. Увеличивать при ЛЮБОМ изменении DDL. */
|
||||
const val CURRENT_VERSION: Int = 1
|
||||
|
||||
const val TABLE = "outbox_cursor"
|
||||
const val COL_ID = "id"
|
||||
const val COL_EPOCH = "epoch"
|
||||
|
||||
/** Хранит **next** offset (эксклюзивную границу), а не последний выданный. */
|
||||
const val COL_OFFSET = "next_offset"
|
||||
|
||||
private val ddl = """
|
||||
CREATE TABLE IF NOT EXISTS $TABLE (
|
||||
$COL_ID INTEGER PRIMARY KEY CHECK ($COL_ID = 1),
|
||||
$COL_EPOCH TEXT NOT NULL,
|
||||
$COL_OFFSET INTEGER NOT NULL
|
||||
);
|
||||
"""
|
||||
|
||||
fun migrate(conn: SQLiteConnection) {
|
||||
conn.exec("BEGIN")
|
||||
try {
|
||||
conn.exec(ddl)
|
||||
conn.exec("COMMIT")
|
||||
} catch (t: Throwable) {
|
||||
runCatching { conn.exec("ROLLBACK") }
|
||||
throw t
|
||||
}
|
||||
}
|
||||
}
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
package pw.binom.agentik.outbox.ksqlite
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNull
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import pw.binom.agentik.outbox.Cursor
|
||||
import pw.binom.agentik.outbox.CursorStore
|
||||
import pw.binom.agentik.outbox.PersistentOffsetSequencer
|
||||
import pw.binom.db.ksqlite.SQLiteConnection
|
||||
|
||||
/** In-memory [CursorStore] для проверки логики секвенсора без БД. */
|
||||
private class FakeCursorStore : CursorStore {
|
||||
var value: Cursor? = null
|
||||
override fun load(): Cursor? = value
|
||||
override fun save(cursor: Cursor) {
|
||||
value = cursor
|
||||
}
|
||||
}
|
||||
|
||||
class PersistentOffsetSequencerTest {
|
||||
|
||||
@Test
|
||||
fun reservesMonotonically() = runTest {
|
||||
val seq = PersistentOffsetSequencer(FakeCursorStore(), newEpoch = { "e1" })
|
||||
assertEquals("e1", seq.epoch())
|
||||
assertEquals(0L, seq.current())
|
||||
assertEquals(0L, seq.reserve())
|
||||
assertEquals(1L, seq.reserve())
|
||||
assertEquals(2L, seq.current())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun seedsInitialNextOnFirstRun() = runTest {
|
||||
val seq = PersistentOffsetSequencer(FakeCursorStore(), initialNext = { 42L }, newEpoch = { "e1" })
|
||||
assertEquals(42L, seq.current())
|
||||
assertEquals(42L, seq.reserve())
|
||||
assertEquals(43L, seq.current())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun survivesRestartKeepingEpochAndOffset() = runTest {
|
||||
val store = FakeCursorStore()
|
||||
val before = PersistentOffsetSequencer(store, newEpoch = { "e1" })
|
||||
before.reserve()
|
||||
before.reserve()
|
||||
|
||||
// «Рестарт»: новый секвенсор над тем же persisted-хранилищем.
|
||||
val after = PersistentOffsetSequencer(store, newEpoch = { "MUST-NOT-BE-USED" })
|
||||
assertEquals("e1", after.epoch())
|
||||
assertEquals(2L, after.current())
|
||||
assertEquals(2L, after.reserve())
|
||||
assertEquals(3L, after.current())
|
||||
}
|
||||
}
|
||||
|
||||
class KsqliteCursorStoreTest {
|
||||
|
||||
@Test
|
||||
fun roundTripsAndSurvivesRestart() {
|
||||
val conn = SQLiteConnection.memory("outbox-cursor-test")
|
||||
try {
|
||||
val first = KsqliteCursorStore(conn)
|
||||
assertNull(first.load())
|
||||
first.save(Cursor(epoch = "e1", offset = 7))
|
||||
assertEquals(Cursor(epoch = "e1", offset = 7), first.load())
|
||||
|
||||
// «Рестарт»: новый store над той же БД.
|
||||
val second = KsqliteCursorStore(conn)
|
||||
assertEquals(Cursor(epoch = "e1", offset = 7), second.load())
|
||||
second.save(Cursor(epoch = "e1", offset = 8))
|
||||
assertEquals(Cursor(epoch = "e1", offset = 8), first.load())
|
||||
|
||||
first.close()
|
||||
second.close()
|
||||
} finally {
|
||||
conn.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user