Добавляет 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:
+70
-6
@@ -32,9 +32,9 @@ import kotlinx.coroutines.withContext
|
||||
* ## Миграция
|
||||
*
|
||||
* [Schema.migrate] прогоняется ВСЕГДА при конструировании — это idempotent
|
||||
* (CREATE TABLE / INDEX IF NOT EXISTS), так что лишних эффектов нет ни в
|
||||
* standalone-форме, ни в shared-connection bundle'е, где несколько store'ов
|
||||
* прогоняют миграцию одной и той же схемы по очереди.
|
||||
* (CREATE TABLE / INDEX IF NOT EXISTS + гейтированный ADD COLUMN), так что
|
||||
* лишних эффектов нет ни в standalone-форме, ни в shared-connection bundle'е,
|
||||
* где несколько store'ов прогоняют миграцию одной и той же схемы по очереди.
|
||||
*
|
||||
* Prepared statements (insert / list / clear) препарируются один раз в
|
||||
* конструкторе и закрываются в [close] ДО закрытия owned connection. Без этого
|
||||
@@ -45,6 +45,12 @@ import kotlinx.coroutines.withContext
|
||||
* `payloadJson` хранит JSON-сериализованные kind-specific поля. encoding
|
||||
* helpers (`encodeRecord` / `toMessageRecord` / `CallPayload` / ...) лежат
|
||||
* в [MessageCodecs.kt] рядом.
|
||||
*
|
||||
* ## Курсор ([MessageRecord.seq])
|
||||
*
|
||||
* [list] с диапазоном `[afterSeq] < seq <= [upToSeq]` — keyset-пагинация,
|
||||
* а не `OFFSET`: конкурентная вставка/удаление сдвигает OFFSET-окно и молча
|
||||
* теряет строки. Индекс `idx_msg_conv_seq` покрывает hot-path.
|
||||
*/
|
||||
class KsqliteJournalStore private constructor(
|
||||
private val connection: SQLiteConnection,
|
||||
@@ -82,14 +88,14 @@ class KsqliteJournalStore private constructor(
|
||||
"""
|
||||
INSERT INTO ${Schema.TABLE_MESSAGE}
|
||||
(${Schema.COL_ID}, ${Schema.COL_CONVERSATION_ID}, ${Schema.COL_KIND},
|
||||
${Schema.COL_PAYLOAD_JSON}, ${Schema.COL_CREATED_AT})
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
${Schema.COL_PAYLOAD_JSON}, ${Schema.COL_CREATED_AT}, ${Schema.COL_SEQ})
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
""".trimIndent()
|
||||
)
|
||||
private val listStmt: SQLitePreparedStatement = connection.prepare(
|
||||
"""
|
||||
SELECT ${Schema.COL_ID}, ${Schema.COL_CONVERSATION_ID}, ${Schema.COL_KIND},
|
||||
${Schema.COL_PAYLOAD_JSON}, ${Schema.COL_CREATED_AT}
|
||||
${Schema.COL_PAYLOAD_JSON}, ${Schema.COL_CREATED_AT}, ${Schema.COL_SEQ}
|
||||
FROM ${Schema.TABLE_MESSAGE}
|
||||
WHERE ${Schema.COL_CONVERSATION_ID} = ?
|
||||
AND ${Schema.COL_CREATED_AT} > ?
|
||||
@@ -97,6 +103,18 @@ class KsqliteJournalStore private constructor(
|
||||
LIMIT ? OFFSET ?
|
||||
""".trimIndent()
|
||||
)
|
||||
private val listSeqStmt: SQLitePreparedStatement = connection.prepare(
|
||||
"""
|
||||
SELECT ${Schema.COL_ID}, ${Schema.COL_CONVERSATION_ID}, ${Schema.COL_KIND},
|
||||
${Schema.COL_PAYLOAD_JSON}, ${Schema.COL_CREATED_AT}, ${Schema.COL_SEQ}
|
||||
FROM ${Schema.TABLE_MESSAGE}
|
||||
WHERE ${Schema.COL_CONVERSATION_ID} = ?
|
||||
AND ${Schema.COL_SEQ} > ?
|
||||
AND ${Schema.COL_SEQ} <= ?
|
||||
ORDER BY ${Schema.COL_SEQ} ASC
|
||||
LIMIT ?
|
||||
""".trimIndent()
|
||||
)
|
||||
private val clearStmt: SQLitePreparedStatement = connection.prepare(
|
||||
"DELETE FROM ${Schema.TABLE_MESSAGE} WHERE ${Schema.COL_CONVERSATION_ID} = ?"
|
||||
)
|
||||
@@ -110,6 +128,13 @@ class KsqliteJournalStore private constructor(
|
||||
AND ${Schema.COL_CREATED_AT} > ?
|
||||
""".trimIndent()
|
||||
)
|
||||
private val countAfterSeqStmt: SQLitePreparedStatement = connection.prepare(
|
||||
"""
|
||||
SELECT COUNT(*) FROM ${Schema.TABLE_MESSAGE}
|
||||
WHERE ${Schema.COL_CONVERSATION_ID} = ?
|
||||
AND ${Schema.COL_SEQ} > ?
|
||||
""".trimIndent()
|
||||
)
|
||||
|
||||
override suspend fun append(record: MessageRecord): Unit = withContext(Dispatchers.Default) {
|
||||
val (kind, payload) = encodeRecord(record)
|
||||
@@ -121,6 +146,7 @@ class KsqliteJournalStore private constructor(
|
||||
insertStmt.bindText(3, kind)
|
||||
insertStmt.bindText(4, payload)
|
||||
insertStmt.bindLong(5, record.createdAt.toEpochMilliseconds())
|
||||
insertStmt.bindLong(6, record.seq)
|
||||
insertStmt.executeUpdate()
|
||||
}
|
||||
}
|
||||
@@ -148,6 +174,29 @@ class KsqliteJournalStore private constructor(
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun list(
|
||||
conversationId: String,
|
||||
afterSeq: Long,
|
||||
upToSeq: Long,
|
||||
limit: Int,
|
||||
): List<MessageRecord> = withContext(Dispatchers.Default) {
|
||||
mutex.withLock {
|
||||
listSeqStmt.reset()
|
||||
listSeqStmt.clearBindings()
|
||||
listSeqStmt.bindText(1, conversationId)
|
||||
listSeqStmt.bindLong(2, afterSeq)
|
||||
listSeqStmt.bindLong(3, upToSeq)
|
||||
listSeqStmt.bindLong(4, limit.toLong())
|
||||
val out = mutableListOf<MessageRecord>()
|
||||
listSeqStmt.executeQuery().use { rs ->
|
||||
while (rs.next()) {
|
||||
out.add(rs.toMessageRecord(json))
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun clear(conversationId: String): Unit = withContext(Dispatchers.Default) {
|
||||
mutex.withLock {
|
||||
clearStmt.reset()
|
||||
@@ -182,12 +231,27 @@ class KsqliteJournalStore private constructor(
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun count(conversationId: String, afterSeq: Long): Long = withContext(Dispatchers.Default) {
|
||||
mutex.withLock {
|
||||
countAfterSeqStmt.reset()
|
||||
countAfterSeqStmt.clearBindings()
|
||||
countAfterSeqStmt.bindText(1, conversationId)
|
||||
countAfterSeqStmt.bindLong(2, afterSeq)
|
||||
countAfterSeqStmt.executeQuery().use { rs ->
|
||||
check(rs.next()) { "COUNT(*) must return at least one row" }
|
||||
(rs.getLong(0) ?: 0L)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
insertStmt.close()
|
||||
listStmt.close()
|
||||
listSeqStmt.close()
|
||||
clearStmt.close()
|
||||
countAllStmt.close()
|
||||
countAfterStmt.close()
|
||||
countAfterSeqStmt.close()
|
||||
if (ownsConnection) {
|
||||
connection.close()
|
||||
}
|
||||
|
||||
+7
-5
@@ -43,26 +43,28 @@ internal fun SQLiteResultSet.toMessageRecord(json: Json): MessageRecord {
|
||||
val kind = getText(2)!!
|
||||
val payload = getText(3)!!
|
||||
val createdAt = Instant.fromEpochMilliseconds(getLong(4)!!)
|
||||
// Колонка `seq` — 6-я (индекс 5) в SELECT'ах store'а.
|
||||
val seq = getLong(5) ?: 0L
|
||||
return when (kind) {
|
||||
"user" -> {
|
||||
val d = decodeBodyPayload(payload)
|
||||
MessageRecord.UserMessage(id = id, conversationId = convId, content = d.content, createdAt = createdAt, context = d.context)
|
||||
MessageRecord.UserMessage(id = id, conversationId = convId, content = d.content, createdAt = createdAt, context = d.context, seq = seq)
|
||||
}
|
||||
"assistant" -> {
|
||||
val d = decodeBodyPayload(payload)
|
||||
MessageRecord.AssistantMessage(id = id, conversationId = convId, content = d.content, createdAt = createdAt, tokens = d.tokens, reasoning = d.reasoning)
|
||||
MessageRecord.AssistantMessage(id = id, conversationId = convId, content = d.content, createdAt = createdAt, tokens = d.tokens, reasoning = d.reasoning, seq = seq)
|
||||
}
|
||||
"tool_call" -> {
|
||||
val p = Json.decodeFromString(CallPayload.serializer(), payload)
|
||||
MessageRecord.ToolCall(id = id, conversationId = convId, toolName = p.name, toolTitle = p.title, toolArgsJson = p.argsJson, createdAt = createdAt)
|
||||
MessageRecord.ToolCall(id = id, conversationId = convId, toolName = p.name, toolTitle = p.title, toolArgsJson = p.argsJson, createdAt = createdAt, seq = seq)
|
||||
}
|
||||
"tool_result" -> {
|
||||
val p = Json.decodeFromString(ResultPayload.serializer(), payload)
|
||||
MessageRecord.ToolResult(id = id, conversationId = convId, toolCallId = p.toolCallId, toolName = p.toolName, result = p.result, createdAt = createdAt)
|
||||
MessageRecord.ToolResult(id = id, conversationId = convId, toolCallId = p.toolCallId, toolName = p.toolName, result = p.result, createdAt = createdAt, seq = seq)
|
||||
}
|
||||
"error" -> {
|
||||
val p = Json.decodeFromString(ErrorPayload.serializer(), payload)
|
||||
MessageRecord.Error(id = id, conversationId = convId, message = p.message, code = p.code, createdAt = createdAt)
|
||||
MessageRecord.Error(id = id, conversationId = convId, message = p.message, code = p.code, createdAt = createdAt, seq = seq)
|
||||
}
|
||||
else -> error("Unknown message kind in audit log: $kind")
|
||||
}
|
||||
|
||||
@@ -16,8 +16,14 @@ import pw.binom.db.ksqlite.SQLiteConnection
|
||||
*/
|
||||
object Schema {
|
||||
|
||||
/** Версия схемы модуля. Увеличивать при ЛЮБОМ изменении DDL. */
|
||||
const val CURRENT_VERSION: Int = 1
|
||||
/**
|
||||
* Версия схемы модуля. Увеличивать при ЛЮБОМ изменении DDL.
|
||||
*
|
||||
* v2: `message.seq` — монотонный per-agent offset (курсор снапшота),
|
||||
* синхронный с `OutboxStore`'ом. Старые БД до-мигрируются через
|
||||
* `ALTER TABLE ... ADD COLUMN` (см. [migrate]).
|
||||
*/
|
||||
const val CURRENT_VERSION: Int = 2
|
||||
|
||||
// ───── Таблицы ─────
|
||||
const val TABLE_CONVERSATION = "conversation"
|
||||
@@ -34,10 +40,14 @@ object Schema {
|
||||
const val COL_CONVERSATION_ID = "conversation_id"
|
||||
const val COL_KIND = "kind"
|
||||
const val COL_PAYLOAD_JSON = "payload_json"
|
||||
/** Монотонный per-agent offset записи (см. `OffsetSequencer`). */
|
||||
const val COL_SEQ = "seq"
|
||||
|
||||
// ───── Индексы ─────
|
||||
const val IDX_CONV_UPDATED = "idx_conv_updated"
|
||||
const val IDX_MSG_CONV = "idx_msg_conv"
|
||||
/** Keyset-индекс для `list(convId, afterSeq, upToSeq, limit)`. */
|
||||
const val IDX_MSG_CONV_SEQ = "idx_msg_conv_seq"
|
||||
|
||||
private val v1ConversationDdl = """
|
||||
CREATE TABLE IF NOT EXISTS $TABLE_CONVERSATION (
|
||||
@@ -49,24 +59,28 @@ object Schema {
|
||||
);
|
||||
"""
|
||||
|
||||
private val v1MessageDdl = """
|
||||
private val v2MessageDdl = """
|
||||
CREATE TABLE IF NOT EXISTS $TABLE_MESSAGE (
|
||||
$COL_ID TEXT NOT NULL PRIMARY KEY,
|
||||
$COL_CONVERSATION_ID TEXT NOT NULL,
|
||||
$COL_KIND TEXT NOT NULL,
|
||||
$COL_PAYLOAD_JSON TEXT NOT NULL,
|
||||
$COL_CREATED_AT INTEGER NOT NULL
|
||||
$COL_CREATED_AT INTEGER NOT NULL,
|
||||
$COL_SEQ INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
"""
|
||||
|
||||
private val v1IndexesDdl = """
|
||||
private val v2IndexesDdl = """
|
||||
CREATE INDEX IF NOT EXISTS $IDX_CONV_UPDATED
|
||||
ON $TABLE_CONVERSATION($COL_UPDATED_AT DESC);
|
||||
|
||||
-- Главный hot-path индекс для list/сообщений: фильтр по conv +
|
||||
-- сортировка по created_at (используется list(), cascade-clear, etc.)
|
||||
-- Legacy hot-path (по времени): list() по createdAt.
|
||||
CREATE INDEX IF NOT EXISTS $IDX_MSG_CONV
|
||||
ON $TABLE_MESSAGE($COL_CONVERSATION_ID, $COL_CREATED_AT);
|
||||
|
||||
-- Cursor hot-path: keyset-пагинация по seq.
|
||||
CREATE INDEX IF NOT EXISTS $IDX_MSG_CONV_SEQ
|
||||
ON $TABLE_MESSAGE($COL_CONVERSATION_ID, $COL_SEQ);
|
||||
"""
|
||||
|
||||
/**
|
||||
@@ -74,7 +88,7 @@ object Schema {
|
||||
*
|
||||
* Гарантии:
|
||||
* - идемпотентность: `CREATE TABLE/INDEX IF NOT EXISTS` — безопасно на
|
||||
* уже-мигрированной БД;
|
||||
* уже-мигрированной БД; `ADD COLUMN` гейтится проверкой `PRAGMA table_info`;
|
||||
* - атомарность: каждая миграция в BEGIN/COMMIT — упал посреди →
|
||||
* ROLLBACK оставит БД консистентной.
|
||||
*
|
||||
@@ -88,12 +102,31 @@ object Schema {
|
||||
conn.exec("BEGIN")
|
||||
try {
|
||||
conn.exec(v1ConversationDdl)
|
||||
conn.exec(v1MessageDdl)
|
||||
conn.exec(v1IndexesDdl)
|
||||
conn.exec(v2MessageDdl)
|
||||
// Старая БД (v1) не получит `seq` от CREATE IF NOT EXISTS —
|
||||
// добавляем колонку, если её ещё нет.
|
||||
if (!columnExists(conn, TABLE_MESSAGE, COL_SEQ)) {
|
||||
conn.exec(
|
||||
"ALTER TABLE $TABLE_MESSAGE ADD COLUMN $COL_SEQ INTEGER NOT NULL DEFAULT 0"
|
||||
)
|
||||
}
|
||||
conn.exec(v2IndexesDdl)
|
||||
conn.exec("COMMIT")
|
||||
} catch (t: Throwable) {
|
||||
runCatching { conn.exec("ROLLBACK") }
|
||||
throw t
|
||||
}
|
||||
}
|
||||
|
||||
private fun columnExists(conn: SQLiteConnection, table: String, column: String): Boolean {
|
||||
conn.prepare("PRAGMA table_info($table)").use { stmt ->
|
||||
stmt.executeQuery().use { rs ->
|
||||
// PRAGMA table_info: (cid, name, type, notnull, dflt_value, pk)
|
||||
while (rs.next()) {
|
||||
if (rs.getText(1) == column) return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user