refactor: outbox становится live-only стримом, cursor вынесен в :cursor-api
Семантика outbox'а — теперь чистый live-канал:
* OutboxStore.conversationEvents(id) — больше не принимает after-cursor.
Catchup (replay) делает клиент: journal.listFlow(afterSeq) + подписка
на live. Outbox ответственен только за уведомления «что-то произошло».
* OutboxStore.oldestCursor()/currentCursor()/OutboxGapException — удалены.
Эпоха и offset живут ТОЛЬКО в CursorHolder/CursorStore, переживают
рестарт и инкремент для каждого commit.
* DurableEvent: commit принимает блок { cursor -> MessageRecord }
(cursor выдаёт CursorStore; клиент не вычисляет offset сам).
* MessageRecord больше не несёт cursor — это не его ответственность.
Новые модули:
* :cursor-api — Cursor(epoch, offset) + CursorHolder / MutableCursorHolder
* :cursor-ksqlite — KsqliteCursorHolder (персистентный)
* :cursor-inmemory — для тестов
* :client-sync — LocalSyncAgent (мини-агент поверх :client для десктопа)
Удалены:
* :sync-core — старая референсная реализация, заменена
cursor-разделением и :client.
* outbox-ksqlite — CursorStore/Schema уехали в :cursor-ksqlite.
* OffsetSequencer / PersistentOffsetSequencer / InMemoryOffsetSequencer.
standalone:
* ChatAgent/ConversationLoop/DurableLog/ToolDispatcher/ReflectionScheduler/
ConversationEvents — подписка через push-паттерн (collect событий).
* A2aBridge — currentCursor() и conversationEvents(after=) убраны.
* SqliteStores — cursor_offset удалён из schema v4; seedNextFromJournal
читает MAX(created_at).
* Main.kt — outboxSequencer → outboxCursorHolder; user→agent (:server)
transport удалён; debug-routes удалены; A2A остался.
* Тесты ChatAgentTest/PersistenceTest переписаны на push-паттерн
(subscribe-before-act, snapshot∪live = итоговое состояние). 25/25 + 19/19 ✅
server / client:
* Routes эпоху читают из CursorHolder; снимки несут Cursor? для catchup.
* AgentikAgent и HttpEventStore — те же подписки, без after-параметра.
* ReconnectingOutbox / ReconnectingOutboxTest — без изменений API.
* JournalStore API расширен count(after=Instant?) для unread-badge.
This commit is contained in:
+9
-74
@@ -32,25 +32,18 @@ import kotlinx.coroutines.withContext
|
||||
* ## Миграция
|
||||
*
|
||||
* [Schema.migrate] прогоняется ВСЕГДА при конструировании — это idempotent
|
||||
* (CREATE TABLE / INDEX IF NOT EXISTS + гейтированный ADD COLUMN), так что
|
||||
* лишних эффектов нет ни в standalone-форме, ни в shared-connection bundle'е,
|
||||
* где несколько store'ов прогоняют миграцию одной и той же схемы по очереди.
|
||||
* (CREATE TABLE / INDEX IF NOT EXISTS), так что лишних эффектов нет ни в
|
||||
* standalone-форме, ни в shared-connection bundle'е, где несколько store'ов
|
||||
* прогоняют миграцию одной и той же схемы по очереди.
|
||||
*
|
||||
* Prepared statements (insert / list / clear) препарируются один раз в
|
||||
* Prepared statements (insert / list / clear / count) препарируются один раз в
|
||||
* конструкторе и закрываются в [close] ДО закрытия owned connection. Без этого
|
||||
* GC финалайзеры каждого StmtHolder'а пытаются `sqlite3_finalize` stmt, чей
|
||||
* parent connection уже закрыт → SIGSEGV в `pthread_mutex_lock`
|
||||
* (см. [pw.binom.db.ksqlite.StmtHolder]).
|
||||
*
|
||||
* `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.
|
||||
* Адресация — по `created_at` (см. [JournalStore]). Курсор/`seq` здесь
|
||||
* отсутствуют: монотонный порядок для удалённого доступа держит sync-слой.
|
||||
*/
|
||||
class KsqliteJournalStore private constructor(
|
||||
private val connection: SQLiteConnection,
|
||||
@@ -88,14 +81,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}, ${Schema.COL_SEQ})
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
${Schema.COL_PAYLOAD_JSON}, ${Schema.COL_CREATED_AT})
|
||||
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_SEQ}
|
||||
${Schema.COL_PAYLOAD_JSON}, ${Schema.COL_CREATED_AT}
|
||||
FROM ${Schema.TABLE_MESSAGE}
|
||||
WHERE ${Schema.COL_CONVERSATION_ID} = ?
|
||||
AND ${Schema.COL_CREATED_AT} > ?
|
||||
@@ -103,18 +96,6 @@ 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} = ?"
|
||||
)
|
||||
@@ -128,13 +109,6 @@ 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)
|
||||
@@ -146,7 +120,6 @@ 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()
|
||||
}
|
||||
}
|
||||
@@ -174,29 +147,6 @@ 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()
|
||||
@@ -231,27 +181,12 @@ 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()
|
||||
}
|
||||
|
||||
+5
-7
@@ -43,28 +43,26 @@ 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, seq = seq)
|
||||
MessageRecord.UserMessage(id = id, conversationId = convId, content = d.content, createdAt = createdAt, context = d.context)
|
||||
}
|
||||
"assistant" -> {
|
||||
val d = decodeBodyPayload(payload)
|
||||
MessageRecord.AssistantMessage(id = id, conversationId = convId, content = d.content, createdAt = createdAt, tokens = d.tokens, reasoning = d.reasoning, seq = seq)
|
||||
MessageRecord.AssistantMessage(id = id, conversationId = convId, content = d.content, createdAt = createdAt, tokens = d.tokens, reasoning = d.reasoning)
|
||||
}
|
||||
"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, seq = seq)
|
||||
MessageRecord.ToolCall(id = id, conversationId = convId, toolName = p.name, toolTitle = p.title, toolArgsJson = p.argsJson, createdAt = createdAt)
|
||||
}
|
||||
"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, seq = seq)
|
||||
MessageRecord.ToolResult(id = id, conversationId = convId, toolCallId = p.toolCallId, toolName = p.toolName, result = p.result, createdAt = createdAt)
|
||||
}
|
||||
"error" -> {
|
||||
val p = Json.decodeFromString(ErrorPayload.serializer(), payload)
|
||||
MessageRecord.Error(id = id, conversationId = convId, message = p.message, code = p.code, createdAt = createdAt, seq = seq)
|
||||
MessageRecord.Error(id = id, conversationId = convId, message = p.message, code = p.code, createdAt = createdAt)
|
||||
}
|
||||
else -> error("Unknown message kind in audit log: $kind")
|
||||
}
|
||||
|
||||
@@ -19,11 +19,13 @@ object Schema {
|
||||
/**
|
||||
* Версия схемы модуля. Увеличивать при ЛЮБОМ изменении DDL.
|
||||
*
|
||||
* v2: `message.seq` — монотонный per-agent offset (курсор снапшота),
|
||||
* синхронный с `OutboxStore`'ом. Старые БД до-мигрируются через
|
||||
* `ALTER TABLE ... ADD COLUMN` (см. [migrate]).
|
||||
* v4: из `message` убраны колонки курсора (`cursor_created_at` /
|
||||
* `cursor_offset`) и индекс `idx_msg_conv_cursor`. Позиция в потоке —
|
||||
* только `created_at`; монотонный курсор для удалённого доступа живёт в
|
||||
* sync-слое. Существующие БД сохраняют legacy-колонки как неиспользуемые
|
||||
* (в DDL они больше не описываются).
|
||||
*/
|
||||
const val CURRENT_VERSION: Int = 2
|
||||
const val CURRENT_VERSION: Int = 4
|
||||
|
||||
// ───── Таблицы ─────
|
||||
const val TABLE_CONVERSATION = "conversation"
|
||||
@@ -40,16 +42,13 @@ 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"
|
||||
/** Hot-path `list(convId, after, offset, limit)` по времени. */
|
||||
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 = """
|
||||
private val conversationDdl = """
|
||||
CREATE TABLE IF NOT EXISTS $TABLE_CONVERSATION (
|
||||
$COL_ID TEXT NOT NULL PRIMARY KEY,
|
||||
$COL_TITLE TEXT,
|
||||
@@ -59,28 +58,22 @@ object Schema {
|
||||
);
|
||||
"""
|
||||
|
||||
private val v2MessageDdl = """
|
||||
private val messageDdl = """
|
||||
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_SEQ INTEGER NOT NULL DEFAULT 0
|
||||
$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
|
||||
);
|
||||
"""
|
||||
|
||||
private val v2IndexesDdl = """
|
||||
private val indexesDdl = """
|
||||
CREATE INDEX IF NOT EXISTS $IDX_CONV_UPDATED
|
||||
ON $TABLE_CONVERSATION($COL_UPDATED_AT DESC);
|
||||
|
||||
-- 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);
|
||||
"""
|
||||
|
||||
/**
|
||||
@@ -88,45 +81,26 @@ object Schema {
|
||||
*
|
||||
* Гарантии:
|
||||
* - идемпотентность: `CREATE TABLE/INDEX IF NOT EXISTS` — безопасно на
|
||||
* уже-мигрированной БД; `ADD COLUMN` гейтится проверкой `PRAGMA table_info`;
|
||||
* уже-мигрированной БД;
|
||||
* - атомарность: каждая миграция в BEGIN/COMMIT — упал посреди →
|
||||
* ROLLBACK оставит БД консистентной.
|
||||
*
|
||||
**NOTE**: в сплит-мире (4 ksqlite-модуля, каждый владеет своей таблицей)
|
||||
* user_version как gate перестал работать — два модуля ставят его в 1,
|
||||
* второй вызов short-circuit'ит. Поэтому migrate() просто прогоняет DDL
|
||||
** NOTE**: в сплит-мире (несколько ksqlite-модулей, каждый владеет своей
|
||||
* таблицей) user_version как gate перестал работать — два модуля ставят его
|
||||
* в 1, второй вызов short-circuit'ит. Поэтому migrate() просто прогоняет DDL
|
||||
* idempotently; координация multi-module миграций — ответственность
|
||||
* вызывающего (см. `pw.binom.agentik.standalone.persistence.SqliteStores`).
|
||||
*/
|
||||
fun migrate(conn: SQLiteConnection) {
|
||||
conn.exec("BEGIN")
|
||||
try {
|
||||
conn.exec(v1ConversationDdl)
|
||||
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(conversationDdl)
|
||||
conn.exec(messageDdl)
|
||||
conn.exec(indexesDdl)
|
||||
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