refactor(storage): drop StorageBundle + legacy EventStore, add HttpEventStore
ci / JVM build + tests (push) Successful in 6m13s
ci / JVM build + tests (push) Successful in 6m13s
- Remove legacy pw.binom.agentik.messageStore.events.EventStore (EventRecord,
EventType) and all three impls (in-memory, sqlite, ksqlite) + tests + .sq
- Drop :storage-bundle module entirely; ChatAgent / ChatConversation /
ConversationLoop / DebugRoutes now take stores individually
(conversationStore, messageStore, workingMemoryStore, reflectionStore,
eventStore) instead of StorageBundle
- Delete server endpoints /events/replay and /conversations/{id}/events/replay;
Route.agentikAgent no longer takes eventStore param
- Add :client/HttpEventStore implementing :event-store/EventStore over HTTP:
events() -> GET /events/all, agentEvents() -> GET /events,
conversationEvents(convId) -> GET /conversations/{id}/events;
exposed via AgentClient.eventStore
- :event-store: add macosX64/macosArm64/linuxArm64 targets to match :client KMP
- :working-memory-api: drop api dep on :message-store-api (no longer needed)
- :storage-{inmemory,sqlite,ksqlite}: drop deps on :storage-bundle
This commit is contained in:
@@ -25,7 +25,6 @@ kotlin {
|
||||
commonMain.dependencies {
|
||||
api(project(":message-store-api"))
|
||||
api(project(":message-log-api"))
|
||||
api(project(":storage-bundle"))
|
||||
api(project(":working-memory-api"))
|
||||
|
||||
// ksqlite ещё не опубликован в Maven Central — только в локальном
|
||||
|
||||
-139
@@ -1,139 +0,0 @@
|
||||
package pw.binom.agentik.storage.ksqlite
|
||||
|
||||
import pw.binom.agentik.messageStore.events.EventRecord
|
||||
import pw.binom.agentik.messageStore.events.EventStore
|
||||
import pw.binom.agentik.messageStore.events.EventType
|
||||
import pw.binom.db.ksqlite.SQLiteConnection
|
||||
import pw.binom.db.ksqlite.SQLitePreparedStatement
|
||||
import pw.binom.db.ksqlite.SQLiteResultSet
|
||||
import kotlin.time.Instant
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
* KMP-реализация [EventStore] поверх ksqlite (https://github.com/caffeine-mgn/ksqlite).
|
||||
*
|
||||
* Схема таблицы `agent_event` повторяет [pw.binom.agentik.storage.sqlite.SqliteEventStore],
|
||||
* чтобы данные были совместимы между двумя backend'ами (можно мигрировать через дамп SQL).
|
||||
*
|
||||
* **Идемпотентность**: append использует `INSERT OR REPLACE` (id — PRIMARY KEY).
|
||||
* Для retry с тем же id и тем же payload это no-op; для retry с тем же id но
|
||||
* другим payload — replace. EventStore contract обещает idempotent no-op; это
|
||||
* ослабление для SQLite (см. [pw.binom.agentik.storage.sqlite.SqliteEventStore]).
|
||||
*
|
||||
* **Thread-safety**: ksqlite API синхронный; один [Mutex] сериализует операции
|
||||
* внутри одного store. Между разными store'ами (если делят [SQLiteConnection])
|
||||
* SQLite сам сериализует через внутренний lock.
|
||||
*
|
||||
* **KMP coverage**: работает на JVM (через JNI к .so), linuxX64/mingwX64
|
||||
* (static C amalgamation), Android Native (нужны NDK headers для включения target'а).
|
||||
*
|
||||
* @param connection открытое соединение с БД. Caller владеет lifecycle —
|
||||
* должен закрыть после [close] EventStore.
|
||||
*/
|
||||
class KsqliteEventStore(
|
||||
private val connection: SQLiteConnection,
|
||||
) : EventStore {
|
||||
|
||||
private val mutex = Mutex()
|
||||
|
||||
/**
|
||||
* Диспатчер для блокирующего SQLite I/O. На JVM и native у [Dispatchers.IO]
|
||||
* разная видимость (на native internal), поэтому для общности используем
|
||||
* [Dispatchers.Default] — на JVM это ~64 worker thread'а, на native — пул
|
||||
* для cinterop-blocking вызовов. Mutex сериализует операции внутри store
|
||||
* и так, так что contention минимальный.
|
||||
*/
|
||||
private companion object {
|
||||
private val IO_DISPATCHER: CoroutineDispatcher = Dispatchers.Default
|
||||
}
|
||||
|
||||
override suspend fun append(record: EventRecord): Unit = withContext(IO_DISPATCHER) {
|
||||
mutex.withLock {
|
||||
connection.prepare(
|
||||
"INSERT OR REPLACE INTO agent_event " +
|
||||
"(id, conversation_id, created_at, type, payload) VALUES (?, ?, ?, ?, ?)"
|
||||
).use { stmt ->
|
||||
stmt.bindText(1, record.id)
|
||||
val convId = record.conversationId
|
||||
if (convId != null) {
|
||||
stmt.bindText(2, convId)
|
||||
} else {
|
||||
stmt.bindNull(2)
|
||||
}
|
||||
stmt.bindLong(3, record.createdAt.toEpochMilliseconds())
|
||||
stmt.bindText(4, record.type.name)
|
||||
stmt.bindText(5, record.payload)
|
||||
stmt.executeUpdate()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun query(
|
||||
conversationId: String?,
|
||||
afterId: String?,
|
||||
limit: Int,
|
||||
): List<EventRecord> = withContext(IO_DISPATCHER) {
|
||||
mutex.withLock {
|
||||
val sql = buildString {
|
||||
append("SELECT id, conversation_id, created_at, type, payload FROM agent_event WHERE 1=1")
|
||||
if (conversationId != null) append(" AND conversation_id = ?")
|
||||
if (afterId != null) append(" AND id > ?")
|
||||
append(" ORDER BY created_at ASC, id ASC LIMIT ?")
|
||||
}
|
||||
connection.prepare(sql).use { stmt ->
|
||||
var idx = 1
|
||||
if (conversationId != null) stmt.bindText(idx++, conversationId)
|
||||
if (afterId != null) stmt.bindText(idx++, afterId)
|
||||
stmt.bindLong(idx, limit.toLong())
|
||||
collectQuery(stmt)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun collectQuery(stmt: SQLitePreparedStatement): List<EventRecord> {
|
||||
val result = mutableListOf<EventRecord>()
|
||||
stmt.executeQuery().use { rs: SQLiteResultSet ->
|
||||
while (rs.next()) {
|
||||
result.add(
|
||||
EventRecord(
|
||||
id = rs.getText(0)!!,
|
||||
conversationId = rs.getText(1),
|
||||
createdAt = Instant.fromEpochMilliseconds(rs.getLong(2)!!),
|
||||
type = runCatching { EventType.valueOf(rs.getText(3)!!) }
|
||||
.getOrDefault(EventType.AGENT_CREATED),
|
||||
payload = rs.getText(4)!!,
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
override suspend fun pruneOlderThan(olderThan: Instant): Int = withContext(IO_DISPATCHER) {
|
||||
mutex.withLock {
|
||||
connection.prepare("DELETE FROM agent_event WHERE created_at < ?").use { stmt ->
|
||||
stmt.bindLong(1, olderThan.toEpochMilliseconds())
|
||||
stmt.executeUpdate()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun count(): Int = withContext(IO_DISPATCHER) {
|
||||
mutex.withLock {
|
||||
// Не нашёл queryForInt в API, делаем через prepare.
|
||||
connection.prepare("SELECT COUNT(*) FROM agent_event").use { stmt ->
|
||||
stmt.executeQuery().use { rs ->
|
||||
if (rs.next()) (rs.getLong(0) ?: 0L).toInt() else 0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
// Connection lifecycle — на caller'е (фабрика KsqliteStores).
|
||||
}
|
||||
}
|
||||
+2
-25
@@ -3,16 +3,14 @@ package pw.binom.agentik.storage.ksqlite
|
||||
import pw.binom.agentik.messageStore.ConversationStore
|
||||
import pw.binom.agentik.messageLog.MessageStore
|
||||
import pw.binom.agentik.messageStore.ReflectionStore
|
||||
import pw.binom.agentik.storageBundle.StorageBundle
|
||||
import pw.binom.agentik.workingMemory.WorkingMemoryStore
|
||||
import pw.binom.agentik.messageStore.events.EventStore
|
||||
import pw.binom.db.ksqlite.SQLiteConnection
|
||||
|
||||
/**
|
||||
* Фабрика всех 5 store'ов из :storage-core поверх ksqlite.
|
||||
* Фабрика 4 store'ов поверх ksqlite.
|
||||
*
|
||||
* Lifecycle: открывает [SQLiteConnection], гарантирует наличие таблиц
|
||||
* (CREATE TABLE IF NOT EXISTS), возвращает bundle из 5 store'ов. Caller
|
||||
* (CREATE TABLE IF NOT EXISTS), возвращает bundle из 4 store'ов. Caller
|
||||
* ДОЛЖЕН вызвать [close] при завершении.
|
||||
*
|
||||
* @param path путь к .db файлу, либо URI для in-memory/shared-cache.
|
||||
@@ -23,24 +21,14 @@ class KsqliteStores private constructor(
|
||||
val messages: MessageStore,
|
||||
val workingMemory: WorkingMemoryStore,
|
||||
val reflections: ReflectionStore,
|
||||
val events: EventStore,
|
||||
) : AutoCloseable {
|
||||
|
||||
fun asBundle(): StorageBundle = StorageBundle(
|
||||
conversationStore = conversations,
|
||||
messageStore = messages,
|
||||
workingMemoryStore = workingMemory,
|
||||
reflectionStore = reflections,
|
||||
eventStore = events,
|
||||
)
|
||||
|
||||
override fun close() {
|
||||
// Закрытие в правильном порядке: зависимые → владелец connection.
|
||||
conversations.close()
|
||||
messages.close()
|
||||
workingMemory.close()
|
||||
reflections.close()
|
||||
events.close()
|
||||
connection.close()
|
||||
}
|
||||
|
||||
@@ -88,16 +76,6 @@ class KsqliteStores private constructor(
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_reflection_created ON reflection(created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_reflection_conv ON reflection(conversation_id, created_at DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS agent_event (
|
||||
id TEXT NOT NULL PRIMARY KEY,
|
||||
conversation_id TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
payload TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_agent_event_conv_time ON agent_event(conversation_id, created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_agent_event_time ON agent_event(created_at);
|
||||
"""
|
||||
|
||||
fun open(path: String): KsqliteStores {
|
||||
@@ -121,7 +99,6 @@ class KsqliteStores private constructor(
|
||||
messages = messages,
|
||||
workingMemory = working,
|
||||
reflections = KsqliteReflectionStore(conn),
|
||||
events = KsqliteEventStore(conn),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
-150
@@ -1,150 +0,0 @@
|
||||
package pw.binom.agentik.storage.ksqlite
|
||||
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import pw.binom.agentik.messageStore.events.EventRecord
|
||||
import pw.binom.agentik.messageStore.events.EventType
|
||||
import kotlin.test.AfterTest
|
||||
import kotlin.test.BeforeTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
import kotlin.time.Instant
|
||||
|
||||
class KsqliteEventStoreTest {
|
||||
|
||||
private lateinit var stores: KsqliteStores
|
||||
|
||||
@BeforeTest
|
||||
fun setup() {
|
||||
stores = KsqliteStores.inMemory("test-${kotlin.random.Random.nextLong()}")
|
||||
}
|
||||
|
||||
@AfterTest
|
||||
fun tearDown() {
|
||||
stores.close()
|
||||
}
|
||||
|
||||
private fun rec(
|
||||
id: String,
|
||||
ts: Long,
|
||||
conv: String? = null,
|
||||
type: EventType = EventType.CONVERSATION_APPEND_TEXT,
|
||||
): EventRecord = EventRecord(
|
||||
id = id,
|
||||
conversationId = conv,
|
||||
createdAt = Instant.fromEpochMilliseconds(ts),
|
||||
type = type,
|
||||
payload = "{\"i\":\"$id\"}",
|
||||
)
|
||||
|
||||
@Test
|
||||
fun testAppendThenQueryReturnsRecord() = runTest {
|
||||
val r = rec("ev-1", ts = 1000)
|
||||
stores.events.append(r)
|
||||
assertEquals(listOf(r), stores.events.query())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testQueryWithConversationIdFilters() = runTest {
|
||||
stores.events.append(rec("ev-1", ts = 1000, conv = "c-1"))
|
||||
stores.events.append(rec("ev-2", ts = 2000, conv = "c-2"))
|
||||
stores.events.append(rec("ev-3", ts = 3000, conv = "c-1"))
|
||||
|
||||
val c1 = stores.events.query(conversationId = "c-1")
|
||||
assertEquals(listOf("ev-1", "ev-3"), c1.map { it.id })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testQueryWithAfterIdReturnsEventsStrictlyAfterCursor() = runTest {
|
||||
stores.events.append(rec("ev-1", ts = 1000))
|
||||
stores.events.append(rec("ev-2", ts = 2000))
|
||||
stores.events.append(rec("ev-3", ts = 3000))
|
||||
|
||||
// id-based cursor: id > 'ev-1' returns ev-2, ev-3
|
||||
assertEquals(listOf("ev-2", "ev-3"), stores.events.query(afterId = "ev-1").map { it.id })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testAppendIsIdempotentOnIdWithInsertOrReplace() = runTest {
|
||||
val r1 = rec("ev-1", ts = 1000, type = EventType.CONVERSATION_APPEND_TEXT)
|
||||
val r2 = rec("ev-1", ts = 1000, type = EventType.CONVERSATION_TOOL_CALL)
|
||||
stores.events.append(r1)
|
||||
stores.events.append(r2)
|
||||
// INSERT OR REPLACE means replace wins. Documented in KsqliteEventStore KDoc.
|
||||
val result = stores.events.query()
|
||||
assertEquals(1, result.size)
|
||||
assertEquals(EventType.CONVERSATION_TOOL_CALL, result[0].type)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testRecordsOrderedByCreatedAtThenId() = runTest {
|
||||
stores.events.append(rec("ev-c", ts = 2000))
|
||||
stores.events.append(rec("ev-a", ts = 1000))
|
||||
stores.events.append(rec("ev-d", ts = 1000))
|
||||
stores.events.append(rec("ev-b", ts = 1500))
|
||||
|
||||
assertEquals(listOf("ev-a", "ev-d", "ev-b", "ev-c"),
|
||||
stores.events.query().map { it.id })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testQueryWithLimitCaps() = runTest {
|
||||
repeat(10) { i -> stores.events.append(rec("ev-$i", ts = (i * 100).toLong())) }
|
||||
val first3 = stores.events.query(limit = 3)
|
||||
assertEquals(3, first3.size)
|
||||
assertEquals(listOf("ev-0", "ev-1", "ev-2"), first3.map { it.id })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testPruneOlderThanRemovesOld() = runTest {
|
||||
stores.events.append(rec("ev-1", ts = 1000))
|
||||
stores.events.append(rec("ev-2", ts = 2000))
|
||||
stores.events.append(rec("ev-3", ts = 3000))
|
||||
|
||||
val removed = stores.events.pruneOlderThan(Instant.fromEpochMilliseconds(2500))
|
||||
assertEquals(2, removed)
|
||||
assertEquals(listOf("ev-3"), stores.events.query().map { it.id })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testCountReturnsTotal() = runTest {
|
||||
assertEquals(0, stores.events.count())
|
||||
stores.events.append(rec("ev-1", ts = 1000))
|
||||
stores.events.append(rec("ev-2", ts = 2000))
|
||||
assertEquals(2, stores.events.count())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testUnknownEventTypeLoadedAsFallback() = runTest {
|
||||
// Forward-compat: write record with unknown type, read via store.
|
||||
stores.connection.exec(
|
||||
"INSERT INTO agent_event (id, conversation_id, created_at, type, payload) VALUES " +
|
||||
"('ev-future', NULL, 5000, 'SOME_FUTURE_TYPE', '{}')"
|
||||
)
|
||||
val result = stores.events.query()
|
||||
assertEquals(1, result.size)
|
||||
assertEquals("ev-future", result[0].id)
|
||||
assertEquals(EventType.AGENT_CREATED, result[0].type)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testNullConversationIdStoredAndRetrieved() = runTest {
|
||||
stores.events.append(rec("ev-no-conv", ts = 1000, conv = null))
|
||||
val result = stores.events.query()
|
||||
assertEquals(1, result.size)
|
||||
assertNull(result[0].conversationId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testSchemaCreatedOnFirstOpen() = runTest {
|
||||
val count = stores.connection.prepare(
|
||||
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='agent_event'"
|
||||
).use { stmt ->
|
||||
stmt.executeQuery().use { rs ->
|
||||
if (rs.next()) (rs.getLong(0) ?: 0L).toInt() else 0
|
||||
}
|
||||
}
|
||||
assertEquals(1, count)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user