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) {