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:
-84
@@ -1,84 +0,0 @@
|
||||
package pw.binom.agentik.storage.inmemory
|
||||
|
||||
import pw.binom.agentik.messageStore.events.EventRecord
|
||||
import pw.binom.agentik.messageStore.events.EventStore
|
||||
import pw.binom.agentik.messageStore.events.EventType
|
||||
import kotlin.time.Instant
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
|
||||
/**
|
||||
* Thread-safe in-memory [EventStore]. Используется в тестах и в dev-режиме
|
||||
* `:standalone` (когда persistent events не нужны — например, для agentik-cli).
|
||||
*
|
||||
* Хранит все events в одном sorted-list (по createdAt). Поиск по afterId —
|
||||
* бинарный (list отсортирован). На 10K events работает за доли ms — для тестов
|
||||
* хватает. На production нужен Sqlite-impl.
|
||||
*
|
||||
* Thread-safety: один [Mutex] на все операции. Не concurrent-write-optimised —
|
||||
* для высоких нагрузок заменить на concurrent skip-list.
|
||||
*/
|
||||
class InMemoryEventStore : EventStore {
|
||||
|
||||
private val all: MutableList<EventRecord> = mutableListOf()
|
||||
private val byId: MutableMap<String, EventRecord> = mutableMapOf()
|
||||
private val mutex = Mutex()
|
||||
|
||||
override suspend fun append(record: EventRecord) {
|
||||
mutex.withLock {
|
||||
// Идемпотентность по id
|
||||
if (byId.containsKey(record.id)) return
|
||||
byId[record.id] = record
|
||||
// Insert maintaining ASC order by createdAt, ties broken by id.
|
||||
// binarySearch returns negative (-insertionPoint - 1) if not found,
|
||||
// or non-negative index if equal element found.
|
||||
val idx = all.binarySearch {
|
||||
val cmp = it.createdAt.compareTo(record.createdAt)
|
||||
if (cmp != 0) cmp else it.id.compareTo(record.id)
|
||||
}
|
||||
if (idx < 0) {
|
||||
all.add(-idx - 1, record)
|
||||
} else {
|
||||
// Found equal element — insert AFTER it to keep insertion order.
|
||||
all.add(idx + 1, record)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun query(
|
||||
conversationId: String?,
|
||||
afterId: String?,
|
||||
limit: Int,
|
||||
): List<EventRecord> {
|
||||
mutex.withLock {
|
||||
val startIdx = if (afterId == null) 0 else {
|
||||
val afterIdx = all.indexOfFirst { it.id == afterId }
|
||||
if (afterIdx < 0) return emptyList()
|
||||
afterIdx + 1
|
||||
}
|
||||
val filtered = if (conversationId == null) {
|
||||
all.subList(startIdx.coerceAtMost(all.size), all.size)
|
||||
} else {
|
||||
all.subList(startIdx.coerceAtMost(all.size), all.size)
|
||||
.filter { it.conversationId == conversationId }
|
||||
}
|
||||
return filtered.take(limit)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun pruneOlderThan(olderThan: Instant): Int {
|
||||
mutex.withLock {
|
||||
val toRemove = all.filter { it.createdAt < olderThan }.map { it.id }
|
||||
if (toRemove.isEmpty()) return 0
|
||||
all.removeAll { it.id in toRemove }
|
||||
toRemove.forEach { byId.remove(it) }
|
||||
return toRemove.size
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun count(): Int = mutex.withLock { all.size }
|
||||
|
||||
override fun close() {
|
||||
// no-op: nothing to release
|
||||
}
|
||||
}
|
||||
+16
-4
@@ -1,10 +1,13 @@
|
||||
package pw.binom.agentik.storage.inmemory
|
||||
|
||||
import pw.binom.agentik.storageBundle.StorageBundle
|
||||
import pw.binom.agentik.messageLog.MessageStore
|
||||
import pw.binom.agentik.messageStore.ConversationStore
|
||||
import pw.binom.agentik.messageStore.ReflectionStore
|
||||
import pw.binom.agentik.workingMemory.WorkingMemoryStore
|
||||
import kotlin.time.Clock
|
||||
|
||||
/**
|
||||
* Фабрика готового [StorageBundle] на базе in-memory имплов.
|
||||
* Фабрика готового набора in-memory store'ов.
|
||||
*
|
||||
* Удобно для:
|
||||
* - тестов (быстрая инициализация, не нужен JDBC driver);
|
||||
@@ -12,13 +15,22 @@ import kotlin.time.Clock
|
||||
* - dry-run / preview, где SQLite не нужен.
|
||||
*
|
||||
* Семантика полностью совпадает с SQLite-имплами (`:storage-sqlite`).
|
||||
*
|
||||
* Возвращает четыре store'а по отдельности — caller пробрасывает их
|
||||
* туда, где нужны (раньше был `StorageBundle`, от него отказались).
|
||||
*/
|
||||
object InMemoryStorage {
|
||||
fun create(clock: Clock = Clock.System): StorageBundle = StorageBundle(
|
||||
data class Bundle(
|
||||
val conversationStore: ConversationStore,
|
||||
val messageStore: MessageStore,
|
||||
val workingMemoryStore: WorkingMemoryStore,
|
||||
val reflectionStore: ReflectionStore,
|
||||
)
|
||||
|
||||
fun create(clock: Clock = Clock.System): Bundle = Bundle(
|
||||
conversationStore = InMemoryConversationStore(clock),
|
||||
messageStore = InMemoryMessageStore(),
|
||||
workingMemoryStore = InMemoryWorkingMemoryStore(),
|
||||
reflectionStore = InMemoryReflectionStore(),
|
||||
eventStore = InMemoryEventStore(),
|
||||
)
|
||||
}
|
||||
|
||||
-165
@@ -1,165 +0,0 @@
|
||||
package pw.binom.agentik.storage.inmemory
|
||||
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import pw.binom.agentik.messageStore.events.EventRecord
|
||||
import pw.binom.agentik.messageStore.events.EventType
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
import kotlin.time.Clock
|
||||
import kotlin.time.Instant
|
||||
|
||||
class InMemoryEventStoreTest {
|
||||
|
||||
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 `append then query returns the record`() = runTest {
|
||||
val store = InMemoryEventStore()
|
||||
val r = rec("ev-1", ts = 1000)
|
||||
store.append(r)
|
||||
val result = store.query()
|
||||
assertEquals(listOf(r), result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `query with afterId returns events strictly after the cursor`() = runTest {
|
||||
val store = InMemoryEventStore()
|
||||
store.append(rec("ev-1", ts = 1000))
|
||||
store.append(rec("ev-2", ts = 2000))
|
||||
store.append(rec("ev-3", ts = 3000))
|
||||
|
||||
// afterId = "ev-1" → only ev-2, ev-3 (exclusive)
|
||||
assertEquals(listOf("ev-2", "ev-3"), store.query(afterId = "ev-1").map { it.id })
|
||||
// afterId = "ev-2" → only ev-3
|
||||
assertEquals(listOf("ev-3"), store.query(afterId = "ev-2").map { it.id })
|
||||
// afterId = null → all
|
||||
assertEquals(listOf("ev-1", "ev-2", "ev-3"), store.query(afterId = null).map { it.id })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `query with afterId pointing at unknown id returns empty`() = runTest {
|
||||
val store = InMemoryEventStore()
|
||||
store.append(rec("ev-1", ts = 1000))
|
||||
assertEquals(emptyList(), store.query(afterId = "ev-unknown"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `query with conversationId filters to that conversation only`() = runTest {
|
||||
val store = InMemoryEventStore()
|
||||
store.append(rec("ev-1", ts = 1000, conv = "c-1"))
|
||||
store.append(rec("ev-2", ts = 2000, conv = "c-2"))
|
||||
store.append(rec("ev-3", ts = 3000, conv = "c-1"))
|
||||
|
||||
val c1 = store.query(conversationId = "c-1")
|
||||
assertEquals(listOf("ev-1", "ev-3"), c1.map { it.id })
|
||||
|
||||
val c2 = store.query(conversationId = "c-2")
|
||||
assertEquals(listOf("ev-2"), c2.map { it.id })
|
||||
|
||||
val all = store.query(conversationId = null)
|
||||
assertEquals(listOf("ev-1", "ev-2", "ev-3"), all.map { it.id })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `query with limit caps the result size`() = runTest {
|
||||
val store = InMemoryEventStore()
|
||||
repeat(10) { i -> store.append(rec("ev-$i", ts = (i * 1000).toLong())) }
|
||||
val first5 = store.query(limit = 5)
|
||||
assertEquals(5, first5.size)
|
||||
assertEquals(listOf("ev-0", "ev-1", "ev-2", "ev-3", "ev-4"), first5.map { it.id })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `append is idempotent on id`() = runTest {
|
||||
val store = InMemoryEventStore()
|
||||
val r1 = rec("ev-1", ts = 1000, type = EventType.CONVERSATION_APPEND_TEXT)
|
||||
val r2 = rec("ev-1", ts = 1000, type = EventType.CONVERSATION_TOOL_CALL) // тот же id, разный тип
|
||||
store.append(r1)
|
||||
store.append(r2)
|
||||
// Second append no-op (idempotent by id) — первая запись побеждает
|
||||
val result = store.query()
|
||||
assertEquals(1, result.size)
|
||||
assertEquals(EventType.CONVERSATION_APPEND_TEXT, result[0].type)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `records returned in createdAt ascending order`() = runTest {
|
||||
val store = InMemoryEventStore()
|
||||
// Insert out of order
|
||||
store.append(rec("ev-b", ts = 2000))
|
||||
store.append(rec("ev-a", ts = 1000))
|
||||
store.append(rec("ev-c", ts = 3000))
|
||||
|
||||
val result = store.query()
|
||||
assertEquals(listOf("ev-a", "ev-b", "ev-c"), result.map { it.id })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `records with same timestamp ordered by id ascending (stable sort)`() = runTest {
|
||||
val store = InMemoryEventStore()
|
||||
store.append(rec("ev-c", ts = 1000))
|
||||
store.append(rec("ev-a", ts = 1000))
|
||||
store.append(rec("ev-b", ts = 1000))
|
||||
|
||||
val result = store.query()
|
||||
// id lexicographic order: a < b < c
|
||||
assertEquals(listOf("ev-a", "ev-b", "ev-c"), result.map { it.id })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pruneOlderThan removes records before cutoff`() = runTest {
|
||||
val store = InMemoryEventStore()
|
||||
store.append(rec("ev-1", ts = 1000))
|
||||
store.append(rec("ev-2", ts = 2000))
|
||||
store.append(rec("ev-3", ts = 3000))
|
||||
|
||||
val removed = store.pruneOlderThan(Instant.fromEpochMilliseconds(2500))
|
||||
assertEquals(2, removed)
|
||||
assertEquals(listOf("ev-3"), store.query().map { it.id })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pruneOlderThan returns 0 when nothing to remove`() = runTest {
|
||||
val store = InMemoryEventStore()
|
||||
store.append(rec("ev-1", ts = 5000))
|
||||
val removed = store.pruneOlderThan(Instant.fromEpochMilliseconds(1000))
|
||||
assertEquals(0, removed)
|
||||
assertEquals(1, store.count())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `count returns number of stored records`() = runTest {
|
||||
val store = InMemoryEventStore()
|
||||
assertEquals(0, store.count())
|
||||
store.append(rec("ev-1", ts = 1000))
|
||||
store.append(rec("ev-2", ts = 2000))
|
||||
assertEquals(2, store.count())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `concurrent append from multiple coroutines all succeed`() = runTest {
|
||||
// Не strict — InMemoryEventStore использует Mutex, поэтому concurrent calls
|
||||
// сериализуются. Тест проверяет, что при последовательных append все ids
|
||||
// попадают в store (для concurrent test нужна отдельная TestScope — это
|
||||
// покрыто integration-тестами в :standalone).
|
||||
val store = InMemoryEventStore()
|
||||
repeat(51) { i ->
|
||||
store.append(rec("ev-$i", ts = i.toLong()))
|
||||
}
|
||||
assertEquals(51, store.count())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user