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:
+132
@@ -0,0 +1,132 @@
|
||||
package pw.binom.agentik.cursor.ksqlite
|
||||
|
||||
import pw.binom.agentik.cursor.Cursor
|
||||
import pw.binom.agentik.cursor.CursorHolder
|
||||
import pw.binom.db.ksqlite.SQLiteConnection
|
||||
import pw.binom.db.ksqlite.SQLitePreparedStatement
|
||||
import kotlin.time.Clock
|
||||
|
||||
/**
|
||||
* ksqlite-реализация [CursorHolder] — таблица `outbox_cursor` (одна строка,
|
||||
* `id = 1`).
|
||||
*
|
||||
* На создании читает сохранённую позицию (`createdAt` + next offset) и держит
|
||||
* её в памяти. Позиция переживает рестарт процесса, поэтому обычный рестарт
|
||||
* сервера **не меняет** `createdAt`, offset'ы остаются монотонными — клиент
|
||||
* продолжает инкрементально, а не получает gap на каждой перезагрузке.
|
||||
*
|
||||
* ## Стартовый offset
|
||||
*
|
||||
* Если таблица пустая (первый запуск / апгрейд БД, где offset уже накоплен),
|
||||
* `createdAt` генерируется ([newCreatedAt]), а next берётся из [initialNext]
|
||||
* (по умолчанию `1`, апгрейд должен передать `maxOffset + 1`).
|
||||
*
|
||||
* ## Смена эпохи
|
||||
*
|
||||
* Смена «мира» (сброс/восстановление БД) — у вызывающего: очисти таблицу,
|
||||
* следующий старт сгенерирует новый `createdAt`.
|
||||
*
|
||||
* ## Lifecycle соединения
|
||||
*
|
||||
* Две формы, как у остальных ksqlite-store'ов:
|
||||
* - `KsqliteCursorHolder(connection, ...)` — внешнее соединение, holder НЕ
|
||||
* закрывает его в [close]. Для shared-connection bundle'а
|
||||
* (`SqliteStores.assemble`).
|
||||
* - `KsqliteCursorHolder(path, ...)` — открывает файловое соединение и
|
||||
* закрывает его в [close].
|
||||
*
|
||||
* `[next]` синхронный — ksqlite-запись блокирующая. Вызовы сериализует
|
||||
* владелец (в `standalone` — мьютекс [pw.binom.agentik.outbox.MutableOutboxStore]),
|
||||
* так что собственная синхронизация не нужна; prepared statements закрываются
|
||||
* в [close] ДО owned-connection (иначе финалайзеры stmt'ов дёргают уже
|
||||
* закрытый parent → SIGSEGV).
|
||||
*/
|
||||
class KsqliteCursorHolder private constructor(
|
||||
private val connection: SQLiteConnection,
|
||||
private val ownsConnection: Boolean,
|
||||
initialNext: () -> ULong,
|
||||
newCreatedAt: () -> Long,
|
||||
) : CursorHolder, AutoCloseable {
|
||||
|
||||
/** Внешнее соединение — holder НЕ закрывает его в [close]. */
|
||||
constructor(
|
||||
connection: SQLiteConnection,
|
||||
initialNext: () -> ULong = { 1u },
|
||||
newCreatedAt: () -> Long = { Clock.System.now().toEpochMilliseconds() },
|
||||
) : this(connection, ownsConnection = false, initialNext, newCreatedAt)
|
||||
|
||||
/** Файловое соединение — holder закрывает его в [close]. */
|
||||
constructor(
|
||||
path: String,
|
||||
initialNext: () -> ULong = { 1u },
|
||||
newCreatedAt: () -> Long = { Clock.System.now().toEpochMilliseconds() },
|
||||
) : this(
|
||||
connection = SQLiteConnection.open(path = path),
|
||||
ownsConnection = true,
|
||||
initialNext,
|
||||
newCreatedAt,
|
||||
)
|
||||
|
||||
init {
|
||||
Schema.migrate(connection)
|
||||
}
|
||||
|
||||
private val getStmt: SQLitePreparedStatement = connection.prepare(
|
||||
"SELECT ${Schema.COL_CREATED_AT}, ${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_CREATED_AT}, ${Schema.COL_OFFSET}) " +
|
||||
"VALUES(1, ?, ?) ON CONFLICT(${Schema.COL_ID}) DO UPDATE SET " +
|
||||
"${Schema.COL_CREATED_AT}=excluded.${Schema.COL_CREATED_AT}, " +
|
||||
"${Schema.COL_OFFSET}=excluded.${Schema.COL_OFFSET}"
|
||||
)
|
||||
|
||||
private val createdAt: Long
|
||||
private var next: ULong
|
||||
|
||||
init {
|
||||
val saved = load()
|
||||
if (saved == null) {
|
||||
createdAt = newCreatedAt()
|
||||
next = initialNext()
|
||||
// Фиксируем createdAt сразу, чтобы он не «прыгал» до первого события.
|
||||
save(Cursor(createdAt = createdAt, offset = next))
|
||||
} else {
|
||||
createdAt = saved.createdAt
|
||||
next = saved.offset
|
||||
}
|
||||
}
|
||||
|
||||
override fun current(): Cursor = Cursor(createdAt, next)
|
||||
|
||||
override fun next(): Cursor {
|
||||
val assigned = Cursor(createdAt, next)
|
||||
next += 1u
|
||||
save(Cursor(createdAt, next))
|
||||
return assigned
|
||||
}
|
||||
|
||||
private fun load(): Cursor? {
|
||||
getStmt.reset()
|
||||
getStmt.clearBindings()
|
||||
getStmt.executeQuery().use { rs ->
|
||||
if (!rs.next()) return null
|
||||
return Cursor(createdAt = rs.getLong(0)!!, offset = rs.getLong(1)!!.toULong())
|
||||
}
|
||||
}
|
||||
|
||||
private fun save(cursor: Cursor) {
|
||||
setStmt.reset()
|
||||
setStmt.clearBindings()
|
||||
setStmt.bindLong(1, cursor.createdAt)
|
||||
setStmt.bindLong(2, cursor.offset.toLong())
|
||||
setStmt.executeUpdate()
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
getStmt.close()
|
||||
setStmt.close()
|
||||
if (ownsConnection) connection.close()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package pw.binom.agentik.cursor.ksqlite
|
||||
|
||||
import pw.binom.db.ksqlite.SQLiteConnection
|
||||
|
||||
/**
|
||||
* Имена таблиц/колонок для ksqlite-реализации [pw.binom.agentik.cursor.CursorHolder].
|
||||
*
|
||||
* Владеет одной таблицей `outbox_cursor` — ровно одна строка (`id = 1`) с
|
||||
* персистентной позицией счётчика событий агента ([Cursor.createdAt] +
|
||||
* next offset), см. [KsqliteCursorHolder].
|
||||
*
|
||||
* `user_version` как gate не используется (split-world: несколько модулей
|
||||
* ставят его независимо) — [migrate] просто идемпотентно прогоняет DDL.
|
||||
*/
|
||||
object Schema {
|
||||
/** Версия схемы модуля. Увеличивать при ЛЮБОМ изменении DDL. */
|
||||
const val CURRENT_VERSION: Int = 2
|
||||
|
||||
const val TABLE = "outbox_cursor"
|
||||
const val COL_ID = "id"
|
||||
|
||||
/** Время создания счётчика (Unix ms) — «эпоха» [pw.binom.agentik.cursor.Cursor]. */
|
||||
const val COL_CREATED_AT = "created_at"
|
||||
|
||||
/** Хранит **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_CREATED_AT INTEGER 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
|
||||
}
|
||||
}
|
||||
}
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
package pw.binom.agentik.cursor.ksqlite
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import pw.binom.agentik.cursor.Cursor
|
||||
import pw.binom.db.ksqlite.SQLiteConnection
|
||||
|
||||
private const val EPOCH = 1_700_000_000_000L
|
||||
|
||||
class KsqliteCursorHolderTest {
|
||||
|
||||
@Test
|
||||
fun generatesMonotonically() {
|
||||
val conn = SQLiteConnection.memory("cursor-holder-test")
|
||||
try {
|
||||
val holder = KsqliteCursorHolder(conn, newCreatedAt = { EPOCH })
|
||||
assertEquals(EPOCH, holder.current().createdAt)
|
||||
assertEquals(Cursor(EPOCH, 1u), holder.current())
|
||||
assertEquals(Cursor(EPOCH, 1u), holder.next())
|
||||
assertEquals(Cursor(EPOCH, 2u), holder.next())
|
||||
assertEquals(Cursor(EPOCH, 3u), holder.current())
|
||||
holder.close()
|
||||
} finally {
|
||||
conn.close()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun seedsInitialNextOnFirstRun() {
|
||||
val conn = SQLiteConnection.memory("cursor-holder-seed")
|
||||
try {
|
||||
val holder = KsqliteCursorHolder(conn, initialNext = { 42u }, newCreatedAt = { EPOCH })
|
||||
assertEquals(Cursor(EPOCH, 42u), holder.current())
|
||||
assertEquals(Cursor(EPOCH, 42u), holder.next())
|
||||
assertEquals(Cursor(EPOCH, 43u), holder.current())
|
||||
holder.close()
|
||||
} finally {
|
||||
conn.close()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun survivesRestartKeepingEpochAndOffset() {
|
||||
val conn = SQLiteConnection.memory("cursor-holder-restart")
|
||||
try {
|
||||
val before = KsqliteCursorHolder(conn, newCreatedAt = { EPOCH })
|
||||
before.next()
|
||||
before.next()
|
||||
|
||||
// «Рестарт»: новый holder над той же БД.
|
||||
val after = KsqliteCursorHolder(conn, newCreatedAt = { EPOCH + 1 })
|
||||
assertEquals(EPOCH, after.current().createdAt)
|
||||
assertEquals(Cursor(EPOCH, 3u), after.current())
|
||||
assertEquals(Cursor(EPOCH, 3u), after.next())
|
||||
assertEquals(Cursor(EPOCH, 4u), after.current())
|
||||
|
||||
before.close()
|
||||
after.close()
|
||||
} finally {
|
||||
conn.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user