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
+2
View File
@@ -17,7 +17,9 @@ dependencies {
testImplementation(project(":core:context"))
testFixturesImplementation(project(":core:events"))
testFixturesImplementation(project(":core:sessions"))
testFixturesImplementation(project(":core:artifacts-store"))
testFixturesImplementation(project(":testing:fixtures"))
testFixturesImplementation "org.jetbrains.kotlinx:kotlinx-datetime:$kotlinx_datetime_version"
testFixturesImplementation "org.junit.jupiter:junit-jupiter:$junit_version"
testFixturesImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$kotlinx_coroutines_version"
}
@@ -0,0 +1,11 @@
package com.correx.testing.contracts.fixtures.artifactstore
import com.correx.core.artifactstore.ArtifactStore
import com.correx.core.events.types.ArtifactId
import com.correx.core.utils.TypeId
class NoopArtifactStore : ArtifactStore {
override suspend fun put(bytes: ByteArray): ArtifactId = TypeId("00".repeat(32))
override suspend fun get(id: ArtifactId): ByteArray? = null
override suspend fun flushBefore(commit: suspend () -> Unit) { commit() }
}
@@ -5,6 +5,7 @@ import com.correx.core.events.types.EventId
import com.correx.core.events.types.SessionId
import com.correx.testing.contracts.utils.ConcurrencyRunner
import com.correx.testing.fixtures.EventFixtures
import kotlinx.coroutines.runBlocking
import org.junit.jupiter.api.Assertions
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.assertThrows
@@ -13,7 +14,7 @@ abstract class EventStoreContractTest {
protected abstract fun store(): EventStore
@Test
fun `appends preserve ordering`() {
fun `appends preserve ordering`(): Unit = runBlocking {
val store = store()
store.append(EventFixtures.newEvent(EventId("e1"), SessionId("s1")))
@@ -27,19 +28,19 @@ abstract class EventStoreContractTest {
}
@Test
fun `idempotency prevents duplicates`() {
fun `idempotency prevents duplicates`(): Unit = runBlocking {
val store = store()
val e = EventFixtures.newEvent(EventId("dup"), SessionId("s1"))
store.append(e)
assertThrows<Exception> {
store.append(e)
runBlocking { store.append(e) }
}
Assertions.assertEquals(1, store.read(SessionId("s1")).size)
}
@Test
fun `sessions are isolated`() {
fun `sessions are isolated`(): Unit = runBlocking {
val store = store()
store.append(EventFixtures.newEvent(EventId("a"), SessionId("s1")))
@@ -50,7 +51,7 @@ abstract class EventStoreContractTest {
}
@Test
fun `readFrom respects sequence cursor`() {
fun `readFrom respects sequence cursor`(): Unit = runBlocking {
val store = store()
store.append(EventFixtures.newEvent(EventId("1"), SessionId("s1")))
@@ -67,9 +68,11 @@ abstract class EventStoreContractTest {
val store = store()
ConcurrencyRunner.run(20, 100) { t, i ->
store.append(
EventFixtures.newEvent(EventId("e-$i-$t"), SessionId("s1"))
)
runBlocking {
store.append(
EventFixtures.newEvent(EventId("e-$i-$t"), SessionId("s1"))
)
}
}
val all = store.read(SessionId("s1"))
@@ -80,7 +83,7 @@ abstract class EventStoreContractTest {
}
@Test
fun `sequences are strictly increasing per session`() {
fun `sequences are strictly increasing per session`(): Unit = runBlocking {
val store = store()
repeat(100) {
@@ -95,7 +98,7 @@ abstract class EventStoreContractTest {
}
@Test
fun `sequences are isolated per session`() {
fun `sequences are isolated per session`(): Unit = runBlocking {
val store = store()
repeat(50) {
@@ -111,7 +114,7 @@ abstract class EventStoreContractTest {
}
@Test
fun `appendAll is atomic per session`() {
fun `appendAll is atomic per session`(): Unit = runBlocking {
val store = store()
val batch = (1..50).map {
@@ -133,12 +136,14 @@ abstract class EventStoreContractTest {
val store = store()
ConcurrencyRunner.run(10, 50) { t, i ->
if (i % 2 == 0) {
store.append(EventFixtures.newEvent(EventId("a-$t-$i"), SessionId("s1")))
} else {
store.appendAll(
listOf(EventFixtures.newEvent(EventId("b-$t-$i"), SessionId("s1")))
)
runBlocking {
if (i % 2 == 0) {
store.append(EventFixtures.newEvent(EventId("a-$t-$i"), SessionId("s1")))
} else {
store.appendAll(
listOf(EventFixtures.newEvent(EventId("b-$t-$i"), SessionId("s1")))
)
}
}
}
@@ -150,7 +155,7 @@ abstract class EventStoreContractTest {
}
@Test
fun `read returns stable snapshot`() {
fun `read returns stable snapshot`(): Unit = runBlocking {
val store = store()
repeat(100) {
@@ -164,7 +169,7 @@ abstract class EventStoreContractTest {
}
@Test
fun `readFrom behaves like streaming cursor`() {
fun `readFrom behaves like streaming cursor`(): Unit = runBlocking {
val store = store()
repeat(10) {
@@ -177,4 +182,4 @@ abstract class EventStoreContractTest {
Assertions.assertTrue(first.all { it.sequence > 3 })
Assertions.assertTrue(second.all { it.sequence > 7 })
}
}
}
@@ -5,6 +5,7 @@ import com.correx.core.events.types.EventId
import com.correx.core.events.types.SessionId
import com.correx.core.sessions.projections.replay.EventReplayer
import com.correx.testing.fixtures.EventFixtures.newEvent
import kotlinx.coroutines.runBlocking
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
@@ -17,7 +18,7 @@ abstract class ReplayContractTest<S> {
): EventReplayer<S>
@Test
fun `rebuild is deterministic`() {
fun `rebuild is deterministic`(): Unit = runBlocking {
val store = store()
repeat(50) {
@@ -6,6 +6,7 @@ import com.correx.core.events.types.SessionId
import com.correx.core.sessions.SessionCounterState
import com.correx.core.sessions.projections.replay.EventReplayer
import com.correx.testing.fixtures.EventFixtures.newEvent
import kotlinx.coroutines.runBlocking
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
@@ -18,7 +19,7 @@ abstract class SessionReplayContractTest {
): EventReplayer<SessionCounterState>
@Test
fun `rebuild is deterministic`() {
fun `rebuild is deterministic`(): Unit = runBlocking {
val store = store()
repeat(50) {
@@ -34,7 +35,7 @@ abstract class SessionReplayContractTest {
}
@Test
fun `rebuild reflects complete stream`() {
fun `rebuild reflects complete stream`(): Unit = runBlocking {
val store = store()
repeat(42) {
@@ -11,6 +11,7 @@ import com.correx.core.sessions.DefaultSessionReducer
import com.correx.core.sessions.SessionProjector
import com.correx.core.sessions.projections.replay.DefaultEventReplayer
import com.correx.infrastructure.persistence.InMemoryEventStore
import kotlinx.coroutines.runBlocking
import kotlinx.datetime.Clock
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
@@ -23,7 +24,7 @@ class SessionReplayDeterminismTest {
)
@Test
fun `same events produce same state`() {
fun `same events produce same state`(): Unit = runBlocking {
val sessionId = SessionId("s1")
val store1 = InMemoryEventStore()
@@ -4,12 +4,14 @@ import com.correx.core.context.model.ContextPack
import com.correx.core.events.events.EventMetadata
import com.correx.core.events.events.InferenceCompletedEvent
import com.correx.core.events.events.NewEvent
import com.correx.core.events.types.ArtifactId
import com.correx.core.events.types.ContextPackId
import com.correx.core.events.types.EventId
import com.correx.core.events.types.InferenceRequestId
import com.correx.core.events.types.ProviderId
import com.correx.core.events.types.SessionId
import com.correx.core.events.types.StageId
import com.correx.core.utils.TypeId
import com.correx.core.inference.FinishReason
import com.correx.core.inference.GenerationConfig
import com.correx.core.inference.InferenceProvider
@@ -73,6 +75,9 @@ object InferenceFixtures {
)
}
private const val PLACEHOLDER_HEX_BYTES = 32
private val PLACEHOLDER_ARTIFACT_ID: ArtifactId = TypeId("00".repeat(PLACEHOLDER_HEX_BYTES))
fun inferenceCompleted(
requestId: InferenceRequestId,
sessionId: SessionId,
@@ -80,6 +85,7 @@ object InferenceFixtures {
providerId: ProviderId,
tokensUsed: TokenUsage,
latencyMs: Long,
responseArtifactId: ArtifactId = PLACEHOLDER_ARTIFACT_ID,
): InferenceCompletedEvent {
return InferenceCompletedEvent(
requestId = requestId,
@@ -88,6 +94,7 @@ object InferenceFixtures {
providerId = providerId,
tokensUsed = tokensUsed,
latencyMs = latencyMs,
responseArtifactId = responseArtifactId,
)
}
@@ -127,6 +134,7 @@ object InferenceFixtures {
providerId: ProviderId,
tokensUsed: TokenUsage,
latencyMs: Long,
responseArtifactId: ArtifactId = PLACEHOLDER_ARTIFACT_ID,
): NewEvent {
return NewEvent(
metadata = EventMetadata(
@@ -144,6 +152,7 @@ object InferenceFixtures {
providerId = providerId,
tokensUsed = tokensUsed,
latencyMs = latencyMs,
responseArtifactId = responseArtifactId,
),
)
}
+2
View File
@@ -16,8 +16,10 @@ dependencies {
testImplementation(project(":core:risk"))
testImplementation(project(":infrastructure:persistence"))
testImplementation(project(":core:artifacts"))
testImplementation(project(":core:artifacts-store"))
testImplementation(project(":testing:fixtures"))
testImplementation(project(":testing:kernel"))
testImplementation(project(":testing:contracts"))
testImplementation(testFixtures(project(":testing:contracts")))
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.9.0")
}
@@ -43,6 +43,12 @@ import com.correx.testing.fixtures.context.ContextFixtures
import com.correx.testing.fixtures.cyclePolicyMissingValidator
import com.correx.testing.fixtures.inference.MockInferenceProvider
import com.correx.testing.fixtures.transitions.TransitionFixtures
import com.correx.core.artifactstore.ArtifactStore
import com.correx.core.events.events.InferenceCompletedEvent
import com.correx.core.events.events.InferenceStartedEvent
import com.correx.core.events.types.ArtifactId
import com.correx.core.utils.TypeId
import com.correx.testing.contracts.fixtures.artifactstore.NoopArtifactStore
import com.correx.testing.fixtures.transitions.TransitionFixtures.threeStageGraph
import com.correx.testing.kernel.MockSessionEventReplayer
import kotlinx.coroutines.launch
@@ -78,6 +84,7 @@ class SessionOrchestratorIntegrationTest {
},
)
private val riskAssessor = DefaultRiskAssessor()
private val artifactStore = NoopArtifactStore()
private val repositories = OrchestratorRepositories(
eventStore = eventStore,
@@ -100,6 +107,7 @@ class SessionOrchestratorIntegrationTest {
repositories = repositories,
engines = engines,
retryCoordinator = retryCoordinator,
artifactStore = artifactStore,
)
@Test
@@ -112,7 +120,6 @@ class SessionOrchestratorIntegrationTest {
orchestrator.run(sessionId, graph, config)
val events = eventStore.read(sessionId)
assertEquals(2, events.size)
val workflowStarted = events.find { it.payload is WorkflowStartedEvent }
assertNotNull(workflowStarted)
@@ -147,6 +154,7 @@ class SessionOrchestratorIntegrationTest {
},
),
retryCoordinator = retryCoordinator,
artifactStore = artifactStore,
)
failingOrchestrator.run(sessionId, graph, config)
@@ -173,6 +181,7 @@ class SessionOrchestratorIntegrationTest {
repositories = repositories,
engines = engines.copy(validationPipeline = approvingPipeline),
retryCoordinator = retryCoordinator,
artifactStore = artifactStore,
)
val runJob = launch { orchestrator.run(sessionId, graph, config) }
@@ -197,7 +206,20 @@ class SessionOrchestratorIntegrationTest {
fun `workflow resumes after approval`(): Unit = runBlocking {
val sessionId = SessionId("s4")
val config = OrchestrationConfig(retryPolicy = RetryPolicy(maxAttempts = 3, backoffMs = 0))
val graph = threeStageGraph()
// Single-stage graph: enterStage(A) triggers exactly one approval; after resume,
// the always-true transition moves to the terminal "done" and the workflow completes.
val graph = WorkflowGraph(
stages = mapOf(StageId("A") to StageConfig()),
transitions = setOf(
com.correx.core.transitions.graph.TransitionEdge(
id = com.correx.core.events.types.TransitionId("t1"),
from = StageId("A"),
to = StageId("done"),
condition = { true },
),
),
start = StageId("A"),
)
val approvingPipeline = ValidationPipeline(
validators = listOf(cyclePolicyMissingValidator()),
approvalTrigger = ApprovalTrigger(),
@@ -206,6 +228,7 @@ class SessionOrchestratorIntegrationTest {
repositories = repositories,
engines = engines.copy(validationPipeline = approvingPipeline),
retryCoordinator = retryCoordinator,
artifactStore = artifactStore,
)
val runJob = launch { approvalOrchestrator.run(sessionId, graph, config) }
@@ -257,6 +280,7 @@ class SessionOrchestratorIntegrationTest {
repositories = repositories,
engines = engines,
retryCoordinator = retryCoordinator,
artifactStore = artifactStore,
)
val sessionId = SessionId("s6")
@@ -269,6 +293,30 @@ class SessionOrchestratorIntegrationTest {
assertNotNull(failed.reason)
}
@Test
fun `artifactStore put is called for prompt and response and ids appear on inference events`(): Unit = runBlocking {
val recordingStore = RecordingArtifactStore()
val sessionId = SessionId("s-artifact")
val config = OrchestrationConfig(retryPolicy = RetryPolicy(maxAttempts = 1, backoffMs = 0))
val recordingOrchestrator = DefaultSessionOrchestrator(
repositories = repositories,
engines = engines,
retryCoordinator = retryCoordinator,
artifactStore = recordingStore,
)
recordingOrchestrator.run(sessionId, graph, config)
assertTrue(recordingStore.puts.size >= 2, "Expected at least 2 puts, got ${recordingStore.puts.size}")
val events = eventStore.read(sessionId)
val started = events.mapNotNull { it.payload as? InferenceStartedEvent }.first()
val completed = events.mapNotNull { it.payload as? InferenceCompletedEvent }.first()
assertEquals(recordingStore.idForIndex(0), started.promptArtifactId)
assertEquals(recordingStore.idForIndex(1), completed.responseArtifactId)
}
@Test
fun `run throws IllegalArgumentException when transition targets undeclared stage`(): Unit = runBlocking {
val a = StageId("A")
@@ -327,3 +375,21 @@ class SessionOrchestratorIntegrationTest {
}
}
private class RecordingArtifactStore : ArtifactStore {
val puts: MutableList<ByteArray> = mutableListOf()
private val byId: MutableMap<ArtifactId, ByteArray> = mutableMapOf()
fun idForIndex(i: Int): ArtifactId = TypeId("0".repeat(63) + i.toString(16))
override suspend fun put(bytes: ByteArray): ArtifactId {
val id = idForIndex(puts.size)
puts.add(bytes)
byId[id] = bytes
return id
}
override suspend fun get(id: ArtifactId): ByteArray? = byId[id]
override suspend fun flushBefore(commit: suspend () -> Unit) { commit() }
}
@@ -9,6 +9,7 @@ import com.correx.core.inference.InferenceRecord
import com.correx.core.inference.InferenceState
import com.correx.core.inference.InferenceStatus
import com.correx.core.inference.TokenUsage
import com.correx.core.utils.TypeId
import com.correx.testing.fixtures.EventFixtures.stored
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.BeforeEach
@@ -38,6 +39,7 @@ class InferenceProjectorTest {
sessionId = SessionId("sess1"),
stageId = StageId("stageA"),
providerId = ProviderId("providerX"),
promptArtifactId = TypeId("00".repeat(32)),
),
)
val newState = projector.apply(initialState, event)
@@ -67,6 +69,7 @@ class InferenceProjectorTest {
providerId = ProviderId("providerX"),
tokensUsed = TokenUsage(50, 50),
latencyMs = 500L,
responseArtifactId = TypeId("00".repeat(32)),
),
)
val newState = projector.apply(initialState, event)
@@ -88,6 +91,7 @@ class InferenceProjectorTest {
sessionId = SessionId("sess1"),
stageId = StageId("stageA"),
providerId = ProviderId("providerX"),
promptArtifactId = TypeId("00".repeat(32)),
),
)
var currentState = projector.apply(initialState, startEvent)
@@ -102,6 +106,7 @@ class InferenceProjectorTest {
providerId = ProviderId("providerX"),
tokensUsed = TokenUsage(100, 100),
latencyMs = 1000L,
responseArtifactId = TypeId("00".repeat(32)),
),
)
currentState = projector.apply(currentState, completeEvent)
@@ -13,6 +13,7 @@ import com.correx.core.inference.InferenceReducer
import com.correx.core.inference.InferenceState
import com.correx.core.inference.InferenceStatus
import com.correx.core.inference.TokenUsage
import com.correx.core.utils.TypeId
import com.correx.testing.fixtures.EventFixtures.stored
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.BeforeEach
@@ -36,6 +37,7 @@ class InferenceReducerTest {
sessionId = SessionId("sess1"),
stageId = StageId("stageA"),
providerId = ProviderId("providerX"),
promptArtifactId = TypeId("00".repeat(32)),
),
)
val newState = reducer.reduce(initialState, event)
@@ -65,6 +67,7 @@ class InferenceReducerTest {
providerId = ProviderId("providerX"),
tokensUsed = TokenUsage(50, 50),
latencyMs = 500L,
responseArtifactId = TypeId("00".repeat(32)),
),
)
val newState = reducer.reduce(initialState, event)
@@ -176,6 +179,7 @@ class InferenceReducerTest {
providerId = ProviderId("providerX"),
tokensUsed = TokenUsage(50, 50),
latencyMs = 500L,
responseArtifactId = TypeId("00".repeat(32)),
),
)
val newState = reducer.reduce(initialState, event)
@@ -23,7 +23,7 @@ class SessionReplayTest {
private val replayer = DefaultEventReplayer(store, projector)
@Test
fun `rebuild session from event stream`() {
fun `rebuild session from event stream`(): Unit = kotlinx.coroutines.runBlocking {
val sessionId = SessionId("s1")
val metadataToPayload = mapOf<EventMetadata, EventPayload>(
@@ -14,6 +14,7 @@ import com.correx.core.sessions.SessionStatus
import com.correx.core.sessions.projections.replay.DefaultEventReplayer
import com.correx.infrastructure.persistence.InMemoryEventStore
import com.correx.testing.fixtures.EventFixtures.newEvent
import kotlinx.coroutines.runBlocking
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
@@ -22,7 +23,7 @@ class TransitionReplayIntegrationTest {
private val sessionId = SessionId("session-1")
@Test
fun `workflow replay reconstructs COMPLETED session`() {
fun `workflow replay reconstructs COMPLETED session`(): Unit = runBlocking {
val store = InMemoryEventStore()
@@ -85,7 +86,7 @@ class TransitionReplayIntegrationTest {
}
@Test
fun `workflow replay reconstructs FAILED session`() {
fun `workflow replay reconstructs FAILED session`(): Unit = runBlocking {
val store = InMemoryEventStore()
@@ -144,7 +145,7 @@ class TransitionReplayIntegrationTest {
}
@Test
fun `replay is deterministic for identical event stream`() {
fun `replay is deterministic for identical event stream`(): Unit = runBlocking {
val events = listOf(
newEvent(
sessionId = sessionId,