feat(acr): ACR Store 1 — content-hash observation cache for repo-file descriptors (#305)
Adds ObservationStore (core:context) keyed on (repoRoot, path), an in-memory default and a durable SqliteObservationStore (infrastructure:persistence), and wires it into the two SessionOrchestratorArtifacts call sites that re-derive a SourceDescriptor from CAS bytes on every call — a hit on matching content hash skips both the CAS read and the regex extraction. First slice of docs/plans/2026-07-21-acr-knowledge-accretion.md (build order: observations before fixes/plan-shapes). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HMbPmZZjcXhR2crU82zZ8S
This commit is contained in:
+76
@@ -0,0 +1,76 @@
|
||||
package com.correx.infrastructure.persistence.observation
|
||||
|
||||
import com.correx.core.context.observation.Observation
|
||||
import com.correx.core.context.observation.ObservationStore
|
||||
import com.correx.infrastructure.persistence.util.JDBCHelper.transaction
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.sql.Connection
|
||||
|
||||
/**
|
||||
* Durable backend for ACR Store 1 (docs/plans/2026-07-21-acr-knowledge-accretion.md), so
|
||||
* observations survive a server restart the way the event log itself does. One row per
|
||||
* (repo_root, path); a `put` overwrites the prior observation for that key outright — this store
|
||||
* only ever holds the latest fact, staleness is resolved by the caller comparing content_hash.
|
||||
*/
|
||||
class SqliteObservationStore(private val connection: Connection) : ObservationStore {
|
||||
|
||||
init {
|
||||
connection.createStatement().use { stmt ->
|
||||
stmt.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS observations (
|
||||
repo_root TEXT NOT NULL,
|
||||
path TEXT NOT NULL,
|
||||
content_hash TEXT NOT NULL,
|
||||
descriptor_render TEXT,
|
||||
observed_at_ms INTEGER NOT NULL,
|
||||
PRIMARY KEY (repo_root, path)
|
||||
)
|
||||
""".trimIndent(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun get(repoRoot: String, path: String): Observation? = withContext(Dispatchers.IO) {
|
||||
connection.prepareStatement(
|
||||
"SELECT content_hash, descriptor_render, observed_at_ms FROM observations " +
|
||||
"WHERE repo_root = ? AND path = ?",
|
||||
).use { stmt ->
|
||||
stmt.setString(1, repoRoot)
|
||||
stmt.setString(2, path)
|
||||
stmt.executeQuery().use { rs ->
|
||||
if (!rs.next()) return@withContext null
|
||||
Observation(
|
||||
repoRoot = repoRoot,
|
||||
path = path,
|
||||
contentHash = rs.getString("content_hash"),
|
||||
descriptorRender = rs.getString("descriptor_render"),
|
||||
observedAtMs = rs.getLong("observed_at_ms"),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("MagicNumber") // JDBC positional parameter indices, not data
|
||||
override suspend fun put(observation: Observation) = withContext(Dispatchers.IO) {
|
||||
connection.transaction {
|
||||
connection.prepareStatement(
|
||||
"INSERT INTO observations (repo_root, path, content_hash, descriptor_render, observed_at_ms) " +
|
||||
"VALUES (?, ?, ?, ?, ?) " +
|
||||
"ON CONFLICT(repo_root, path) DO UPDATE SET " +
|
||||
"content_hash = excluded.content_hash, " +
|
||||
"descriptor_render = excluded.descriptor_render, " +
|
||||
"observed_at_ms = excluded.observed_at_ms",
|
||||
).use { stmt ->
|
||||
stmt.setString(1, observation.repoRoot)
|
||||
stmt.setString(2, observation.path)
|
||||
stmt.setString(3, observation.contentHash)
|
||||
stmt.setString(4, observation.descriptorRender)
|
||||
stmt.setLong(5, observation.observedAtMs)
|
||||
stmt.executeUpdate()
|
||||
}
|
||||
}
|
||||
Unit
|
||||
}
|
||||
}
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
package com.correx.infrastructure.persistence.observation
|
||||
|
||||
import com.correx.core.context.observation.Observation
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import java.sql.DriverManager
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNull
|
||||
|
||||
class SqliteObservationStoreTest {
|
||||
|
||||
private fun store() = SqliteObservationStore(DriverManager.getConnection("jdbc:sqlite::memory:"))
|
||||
|
||||
@Test
|
||||
fun `put then get round-trips through sqlite`(): Unit = runBlocking {
|
||||
val store = store()
|
||||
store.put(Observation("repoA", "src/Foo.kt", "hash1", "module=foo", 100L))
|
||||
|
||||
val fetched = store.get("repoA", "src/Foo.kt")
|
||||
|
||||
assertEquals(Observation("repoA", "src/Foo.kt", "hash1", "module=foo", 100L), fetched)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `put overwrites the prior row for the same key`(): Unit = runBlocking {
|
||||
val store = store()
|
||||
store.put(Observation("repoA", "src/Foo.kt", "hash1", "old", 1L))
|
||||
store.put(Observation("repoA", "src/Foo.kt", "hash2", "new", 2L))
|
||||
|
||||
assertEquals("hash2", store.get("repoA", "src/Foo.kt")?.contentHash)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unknown key returns null`(): Unit = runBlocking {
|
||||
assertNull(store().get("repoA", "nope"))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user