refactor(acr): ACR Store 1 as a disposable memo, not a durable side-store (#305)
The shipped Store 1 persisted repo-file descriptors in a new SqliteObservationStore — a second, unsynchronized SQLite writer on the event-log DB (no WAL/busy_timeout, one shared Connection across coroutines) holding a fact the log already carries. That violates "the event log is the only source of truth" (inv #1/#8): an Observation is just FileWrittenEvent.path + postImageHash + describe(bytes).render(), and render is a pure function of content-addressed CAS bytes. Replace the whole durable apparatus with an in-process memo (descriptorMemo, keyed on repoRoot+path+contentHash) on the orchestrator, next to the existing artifactContentCache. Rebuilt from the log on restart, disposable, no external store. Preserves the perf win (skip CAS read + regex on repeat describes) with none of the concurrency/lock risk. Deletes: ObservationStore/InMemoryObservationStore/SqliteObservationStore (+ tests), InfrastructureModule.createObservationStore, the FileReadTool priming path (which never hit anyway — it keyed on a workspace-relative path while every consumer looks up the absolute FileWrittenEvent.path), the FileReadConfig/SessionOrchestrator plumbing, and five orphaned build.gradle deps. Store 2 (soft-confidence hints) unchanged — it was correct. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HMbPmZZjcXhR2crU82zZ8S
This commit is contained in:
-76
@@ -1,76 +0,0 @@
|
||||
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
@@ -1,37 +0,0 @@
|
||||
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