feat(cas): content-addressed artifact store (steps 1–8)

New core:artifacts-store interface + infrastructure/artifacts-cas
implementation: segment files + SQLite index, Blake3 hashing,
group-commit fsync via flushBefore, recovery tail-scan, manual
compactor, and oldest-first disk-cap evictor.

Inference events now carry promptArtifactId / responseArtifactId;
orchestrators put bytes before emitting. SqliteEventStore wraps
its txn in artifactStore.flushBefore so segment data is fsynced
before the event commit, making the crash window non-corrupting
(TailScanner re-indexes orphan tail records on reopen).

Compactor and evictor are mutually exclusive via maintenanceMutex.
Step 9 (cloud sync) deferred to a later epic.

See docs/reviews/2026-05-18-cas-steps-1-8-review.md for the final
review.
This commit is contained in:
2026-05-18 12:22:38 +04:00
parent bbff73108e
commit 219e2c762e
60 changed files with 2042 additions and 165 deletions
+1
View File
@@ -9,6 +9,7 @@ dependencies {
implementation(project(":core:events"))
implementation(project(":core:sessions"))
implementation(project(":core:artifacts"))
implementation(project(":core:artifacts-store"))
implementation "org.xerial:sqlite-jdbc"
testImplementation(testFixtures(project(":testing:contracts")))
testImplementation "org.junit.jupiter:junit-jupiter"
@@ -16,7 +16,7 @@ class InMemoryEventStore : EventStore {
private val seenEventIds = ConcurrentHashMap.newKeySet<EventId>()
private val subscriptions = ConcurrentHashMap<SessionId, MutableSharedFlow<StoredEvent>>()
override fun append(event: NewEvent): StoredEvent {
override suspend fun append(event: NewEvent): StoredEvent {
val stream = streams.computeIfAbsent(event.metadata.sessionId) { mutableListOf() }
synchronized(stream) {
@@ -27,7 +27,7 @@ class InMemoryEventStore : EventStore {
}
}
override fun appendAll(events: List<NewEvent>): List<StoredEvent> {
override suspend fun appendAll(events: List<NewEvent>): List<StoredEvent> {
if (events.isEmpty()) return emptyList()
val sessionId = events.first().metadata.sessionId
@@ -58,6 +58,11 @@ class InMemoryEventStore : EventStore {
override fun subscribe(sessionId: SessionId): Flow<StoredEvent> =
subscriptions.computeIfAbsent(sessionId) { MutableSharedFlow(replay = 0, extraBufferCapacity = 64) }
override fun allEvents(): Sequence<StoredEvent> =
streams.values
.flatMap { stream -> synchronized(stream) { stream.toList() } }
.asSequence()
private fun doAppend(event: NewEvent, stream: MutableList<StoredEvent>): StoredEvent {
val seq = sequences.computeIfAbsent(event.metadata.sessionId) { AtomicLong(0) }
.incrementAndGet()
@@ -1,5 +1,6 @@
package com.correx.infrastructure.persistence
import com.correx.core.artifactstore.ArtifactStore
import com.correx.core.events.events.EventMetadata
import com.correx.core.events.events.NewEvent
import com.correx.core.events.events.StoredEvent
@@ -11,8 +12,10 @@ import com.correx.core.events.types.CorrelationId
import com.correx.core.events.types.EventId
import com.correx.core.events.types.SessionId
import com.correx.infrastructure.persistence.util.JDBCHelper.transaction
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.withContext
import kotlinx.datetime.Instant
import java.sql.Connection
import java.sql.ResultSet
@@ -20,7 +23,8 @@ import java.util.concurrent.ConcurrentHashMap
class SqliteEventStore(
private val connection: Connection,
private val jsonSerializer: JsonEventSerializer = JsonEventSerializer(eventJson)
private val jsonSerializer: JsonEventSerializer = JsonEventSerializer(eventJson),
private val artifactStore: ArtifactStore,
) : EventStore {
private val subscriptions = ConcurrentHashMap<SessionId, MutableSharedFlow<StoredEvent>>()
@@ -43,46 +47,57 @@ class SqliteEventStore(
}
}
override fun append(event: NewEvent): StoredEvent {
val stored = connection.transaction {
val existing = findByEventId(event.metadata.eventId)
check(existing == null) { "duplicate event_id: ${event.metadata.eventId}" }
override suspend fun append(event: NewEvent): StoredEvent {
var stored: StoredEvent? = null
artifactStore.flushBefore {
withContext(Dispatchers.IO) {
stored = connection.transaction {
val existing = findByEventId(event.metadata.eventId)
check(existing == null) { "duplicate event_id: ${event.metadata.eventId}" }
val seq = nextSequence(event.metadata.sessionId)
val seq = nextSequence(event.metadata.sessionId)
val s = StoredEvent(
metadata = event.metadata,
sequence = seq,
payload = event.payload
)
val s = StoredEvent(
metadata = event.metadata,
sequence = seq,
payload = event.payload
)
insert(s)
s
insert(s)
s
}
}
}
subscriptions[event.metadata.sessionId]?.tryEmit(stored)
return stored
val result = checkNotNull(stored)
subscriptions[event.metadata.sessionId]?.tryEmit(result)
return result
}
override fun appendAll(events: List<NewEvent>): List<StoredEvent> {
override suspend fun appendAll(events: List<NewEvent>): List<StoredEvent> {
if (events.isEmpty()) return emptyList()
val sessionId = events.first().metadata.sessionId
val stored = connection.transaction {
events.map { event ->
val existing = findByEventId(event.metadata.eventId)
check(existing == null) { "duplicate event_id: ${event.metadata.eventId}" }
var stored: List<StoredEvent> = emptyList()
artifactStore.flushBefore {
withContext(Dispatchers.IO) {
stored = connection.transaction {
events.map { event ->
val existing = findByEventId(event.metadata.eventId)
check(existing == null) { "duplicate event_id: ${event.metadata.eventId}" }
val seq = nextSequence(sessionId)
val seq = nextSequence(sessionId)
val s = StoredEvent(
metadata = event.metadata,
sequence = seq,
payload = event.payload
)
val s = StoredEvent(
metadata = event.metadata,
sequence = seq,
payload = event.payload
)
insert(s)
s
insert(s)
s
}
}
}
}
val flow = subscriptions[sessionId]
@@ -103,7 +118,7 @@ class SqliteEventStore(
ps.executeQuery().use { rs ->
buildList {
while (rs.next()) {
add(map(rs))
add(rs.toStoredEvent(jsonSerializer))
}
}
}
@@ -124,7 +139,7 @@ class SqliteEventStore(
ps.executeQuery().use { rs ->
buildList {
while (rs.next()) {
add(map(rs))
add(rs.toStoredEvent(jsonSerializer))
}
}
}
@@ -149,6 +164,22 @@ class SqliteEventStore(
override fun subscribe(sessionId: SessionId): Flow<StoredEvent> =
subscriptions.computeIfAbsent(sessionId) { MutableSharedFlow(replay = 0, extraBufferCapacity = 64) }
override fun allEvents(): Sequence<StoredEvent> =
connection.prepareStatement(
"""
SELECT * FROM events
ORDER BY session_id ASC, sequence ASC
""".trimIndent()
).use { ps ->
ps.executeQuery().use { rs ->
buildList {
while (rs.next()) {
add(rs.toStoredEvent(jsonSerializer))
}
}
}
}.asSequence()
// ---------- helpers ----------
private fun nextSequence(sessionId: SessionId): Long =
@@ -205,21 +236,22 @@ class SqliteEventStore(
ps.setString(1, eventId.value)
ps.executeQuery().use { rs ->
if (rs.next()) map(rs) else null
if (rs.next()) rs.toStoredEvent(jsonSerializer) else null
}
}
private fun map(rs: ResultSet): StoredEvent =
StoredEvent(
metadata = EventMetadata(
eventId = EventId(rs.getString("event_id")),
sessionId = SessionId(rs.getString("session_id")),
timestamp = Instant.parse(rs.getString("timestamp")),
schemaVersion = rs.getInt("schema_version"),
causationId = rs.getString("causation_id")?.let { CausationId(it) },
correlationId = rs.getString("correlation_id")?.let { CorrelationId(it) }
),
sequence = rs.getLong("sequence"),
payload = jsonSerializer.deserialize(rs.getString("payload"))
)
}
}
private fun ResultSet.toStoredEvent(jsonSerializer: JsonEventSerializer): StoredEvent =
StoredEvent(
metadata = EventMetadata(
eventId = EventId(getString("event_id")),
sessionId = SessionId(getString("session_id")),
timestamp = Instant.parse(getString("timestamp")),
schemaVersion = getInt("schema_version"),
causationId = getString("causation_id")?.let { CausationId(it) },
correlationId = getString("correlation_id")?.let { CorrelationId(it) }
),
sequence = getLong("sequence"),
payload = jsonSerializer.deserialize(getString("payload"))
)
@@ -1,12 +1,13 @@
package com.correx.infrastructure.persistence
import com.correx.core.events.stores.EventStore
import com.correx.testing.contracts.fixtures.artifactstore.NoopArtifactStore
import com.correx.testing.contracts.fixtures.events.store.EventStoreContractTest
import java.sql.DriverManager
class SqliteEventStoreTest : EventStoreContractTest() {
override fun store(): EventStore {
val conn = DriverManager.getConnection("jdbc:sqlite::memory:")
return SqliteEventStore(conn)
return SqliteEventStore(conn, artifactStore = NoopArtifactStore())
}
}
@@ -5,6 +5,7 @@ import com.correx.core.sessions.SessionCounterProjection
import com.correx.core.sessions.SessionCounterState
import com.correx.core.sessions.projections.replay.DefaultEventReplayer
import com.correx.core.sessions.projections.replay.EventReplayer
import com.correx.testing.contracts.fixtures.artifactstore.NoopArtifactStore
import com.correx.testing.contracts.fixtures.projections.SessionReplayContractTest
import java.sql.DriverManager
@@ -14,7 +15,7 @@ class SqliteReplayTest : SessionReplayContractTest() {
val connection =
DriverManager.getConnection("jdbc:sqlite::memory:")
return SqliteEventStore(connection)
return SqliteEventStore(connection, artifactStore = NoopArtifactStore())
}
override fun replayer(