feat(journal-inmemory): add :journal-inmemory module with InMemoryJournalStore
ci / JVM build + tests (push) Failing after 1m2s
ci / JVM build + tests (push) Failing after 1m2s
- Introduced a new `:journal-inmemory` module implementing `:journal-api` with an in-memory backend. - Added `InMemoryJournalStore` for concurrent append, list, and clear operations using `Mutex` and `MutableList`. - Use cases include tests, dev mode, embedded scenarios, and client-side in-process caching. - Integrated the module into the project setup and documented usage in `:client/README.md`. - Added comprehensive
This commit is contained in:
+68
@@ -0,0 +1,68 @@
|
||||
package pw.binom.agentik.journal.inmemory
|
||||
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import pw.binom.agentik.journal.JournalStore
|
||||
import pw.binom.agentik.journal.MessageRecord
|
||||
import pw.binom.agentik.journal.MutableJournalStore
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
* Простая in-memory [MutableJournalStore] для тестов, dev-режима и
|
||||
* клиентских in-process кэшей.
|
||||
*
|
||||
* **Thread-safety**: `Mutex` поверх `MutableList<MessageRecord>`. Для
|
||||
* embedded/CLI сценариев достаточно; для hot-path на сервере используйте
|
||||
* [pw.binom.agentik.journal.ksqlite.KsqliteJournalStore].
|
||||
*
|
||||
* **Контракт `list`**: возвращает подмножество с
|
||||
* `conversationId == conversationId && createdAt > after`, отсортированное
|
||||
* по `createdAt ASC`. `offset/limit` — paging поверх отфильтрованного списка.
|
||||
*
|
||||
* **Очистка**: [clear] сбрасывает кэш (например, когда диалог удалён
|
||||
* на сервере). [close] — no-op.
|
||||
*
|
||||
* Типичный кэш-паттерн в клиенте:
|
||||
* ```
|
||||
* val local = InMemoryJournalStore()
|
||||
* val remote = HttpJournalStore(httpClient, baseUrl)
|
||||
* // backfill + кэширование:
|
||||
* remote.listFlow(convId, Instant.DISTANT_PAST).collect { local.append(it) }
|
||||
* // после этого `local.list(convId, after, offset, limit)` отдаёт из кэша.
|
||||
* ```
|
||||
*/
|
||||
class InMemoryJournalStore : MutableJournalStore {
|
||||
|
||||
private val mutex = Mutex()
|
||||
private val records: MutableList<MessageRecord> = mutableListOf()
|
||||
|
||||
override suspend fun append(record: MessageRecord): Unit = mutex.withLock {
|
||||
records.add(record)
|
||||
}
|
||||
|
||||
override suspend fun list(
|
||||
conversationId: String,
|
||||
after: Instant,
|
||||
offset: Int,
|
||||
limit: Int,
|
||||
): List<MessageRecord> = mutex.withLock {
|
||||
records.asSequence()
|
||||
.filter { it.conversationId == conversationId && it.createdAt > after }
|
||||
.sortedBy { it.createdAt }
|
||||
.drop(offset)
|
||||
.take(limit)
|
||||
.toList()
|
||||
}
|
||||
|
||||
/** Сбросить кэш (например, когда диалог удалён). */
|
||||
suspend fun clear(): Unit = mutex.withLock {
|
||||
records.clear()
|
||||
}
|
||||
|
||||
/** Сколько записей сейчас в кэше. Для тестов/диагностики. */
|
||||
suspend fun size(): Int = mutex.withLock { records.size }
|
||||
|
||||
override fun close() {
|
||||
// no-op: lifecycle HttpClient'а — снаружи.
|
||||
}
|
||||
}
|
||||
+78
@@ -0,0 +1,78 @@
|
||||
package pw.binom.agentik.journal.inmemory
|
||||
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import pw.binom.agentik.journal.MessageRecord
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
import kotlin.time.Instant
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class InMemoryJournalStoreTest {
|
||||
|
||||
private fun userMsg(id: String, convId: String, text: String, at: Instant) =
|
||||
MessageRecord.UserMessage(
|
||||
id = id,
|
||||
conversationId = convId,
|
||||
content = listOf(pw.binom.agentik.journal.Content.Text(text)),
|
||||
createdAt = at,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `append then list returns records sorted by createdAt ASC`() = runTest {
|
||||
val store = InMemoryJournalStore()
|
||||
val t0 = Instant.parse("2026-09-21T10:00:00Z")
|
||||
store.append(userMsg("m1", "c1", "first", t0))
|
||||
store.append(userMsg("m2", "c1", "second", t0 + 1.seconds))
|
||||
store.append(userMsg("m3", "c1", "third", t0 + 2.seconds))
|
||||
|
||||
val all = store.list("c1", Instant.DISTANT_PAST, 0, 100)
|
||||
assertEquals(3, all.size)
|
||||
assertEquals(listOf("m1", "m2", "m3"), all.map { it.id })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `list filters by conversationId`() = runTest {
|
||||
val store = InMemoryJournalStore()
|
||||
val t0 = Instant.parse("2026-09-21T10:00:00Z")
|
||||
store.append(userMsg("m1", "c1", "a", t0))
|
||||
store.append(userMsg("m2", "c2", "b", t0 + 1.seconds))
|
||||
store.append(userMsg("m3", "c1", "c", t0 + 2.seconds))
|
||||
|
||||
assertEquals(2, store.list("c1", Instant.DISTANT_PAST, 0, 100).size)
|
||||
assertEquals(1, store.list("c2", Instant.DISTANT_PAST, 0, 100).size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `list filters by after cursor`() = runTest {
|
||||
val store = InMemoryJournalStore()
|
||||
val t0 = Instant.parse("2026-09-21T10:00:00Z")
|
||||
store.append(userMsg("m1", "c1", "a", t0))
|
||||
store.append(userMsg("m2", "c1", "b", t0 + 10.seconds))
|
||||
store.append(userMsg("m3", "c1", "c", t0 + 20.seconds))
|
||||
|
||||
val afterT0 = store.list("c1", t0, 0, 100)
|
||||
assertEquals(listOf("m2", "m3"), afterT0.map { it.id })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `list applies offset and limit`() = runTest {
|
||||
val store = InMemoryJournalStore()
|
||||
val t0 = Instant.parse("2026-09-21T10:00:00Z")
|
||||
repeat(10) { i -> store.append(userMsg("m$i", "c1", "x", t0 + i.seconds)) }
|
||||
|
||||
val page = store.list("c1", Instant.DISTANT_PAST, offset = 3, limit = 4)
|
||||
assertEquals(listOf("m3", "m4", "m5", "m6"), page.map { it.id })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clear empties the cache`() = runTest {
|
||||
val store = InMemoryJournalStore()
|
||||
val t0 = Instant.parse("2026-09-21T10:00:00Z")
|
||||
store.append(userMsg("m1", "c1", "x", t0))
|
||||
assertEquals(1, store.size())
|
||||
store.clear()
|
||||
assertEquals(0, store.size())
|
||||
assertTrue(store.list("c1", Instant.DISTANT_PAST, 0, 100).isEmpty())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user