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:
@@ -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"
|
||||
|
||||
+7
-2
@@ -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()
|
||||
|
||||
+77
-45
@@ -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"))
|
||||
)
|
||||
+2
-1
@@ -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())
|
||||
}
|
||||
}
|
||||
+2
-1
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user