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:
@@ -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"
|
||||
}
|
||||
|
||||
+11
@@ -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() }
|
||||
}
|
||||
+25
-20
@@ -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 })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -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) {
|
||||
|
||||
+3
-2
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user