fix(quartz/sqlite): serialise writes via a Room-style connection pool

androidx.sqlite SQLiteConnection is not thread-safe; SQLiteEventStore
shared a single lazy connection across all callers, so two coroutines
calling insertEvent() at the same time would race on BEGIN IMMEDIATE
and the modules' prepared statements, surfacing as
"cannot start a transaction within a transaction" or SQLITE_MISUSE.

Mirror Room's design: introduce SQLiteConnectionPool with one writer
connection guarded by a coroutine Mutex and N reader connections
handed out via a Channel-as-semaphore (file-backed DBs only; in-memory
DBs share the writer because each ":memory:" connection is a separate
DB). Convert IEventStore + SQLiteEventStore + EventStore + FsEventStore
+ LiveEventStore to suspend, route writes through useWriter and reads
through useReader. RelaySession now launches handleEvent / handleCount
on its scope. CLI Context helpers and StoreCommands.sweepExpired pick
up suspend.

Add ParallelInsertTest to lock the behaviour in: 8 coroutines × 200
inserts, parallel reads alongside writes, transaction batches across
coroutines, and a reopen smoke test all pass against a file-backed DB.

https://claude.ai/code/session_016b5kSSbtDS3Ead6pN3Xqt5
This commit is contained in:
Claude
2026-04-26 13:25:30 +00:00
parent de31d37c01
commit 9fcf85bed0
29 changed files with 2264 additions and 1750 deletions
@@ -24,7 +24,7 @@ import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import kotlin.test.assertEquals
fun <T : Event> EventStore.assertQuery(
suspend fun <T : Event> EventStore.assertQuery(
expected: T?,
filter: Filter,
) {
@@ -40,7 +40,7 @@ fun <T : Event> EventStore.assertQuery(
}
}
fun <T : Event> EventStore.assertQuery(
suspend fun <T : Event> EventStore.assertQuery(
expected: List<T>,
filter: Filter,
) {
@@ -53,7 +53,7 @@ fun <T : Event> EventStore.assertQuery(
}
}
fun <T : Event> SQLiteEventStore.assertQuery(
suspend fun <T : Event> SQLiteEventStore.assertQuery(
expected: T?,
filter: Filter,
) {
@@ -69,7 +69,7 @@ fun <T : Event> SQLiteEventStore.assertQuery(
}
}
fun <T : Event> SQLiteEventStore.assertQuery(
suspend fun <T : Event> SQLiteEventStore.assertQuery(
expected: List<T>,
filter: Filter,
) {
@@ -307,10 +307,14 @@ class BasicTest : BaseDBTest() {
// modules.forEach { it.create(db) }. Pre-fix, FullTextSearchModule
// left dummy_fts3/4/5 tables behind on first probe, so the
// second create() would throw "already exists".
db.store.modules
.reversed()
.forEach { it.drop(db.store.connection) }
db.store.modules.forEach { it.create(db.store.connection) }
// Drive the module re-create against the writer connection
// (drop + create touches schema, so we need exclusive access).
db.store.pool.useWriter { conn ->
db.store.modules
.reversed()
.forEach { it.drop(conn) }
db.store.modules.forEach { it.create(conn) }
}
// After re-creation the store is still usable.
val note = signer.sign(TextNoteEvent.build("test1"))
@@ -26,6 +26,7 @@ import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper
import com.vitorpamplona.quartz.nip40Expiration.isExpired
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.runBlocking
import kotlin.test.AfterTest
import kotlin.test.BeforeTest
import kotlin.test.Test
@@ -56,24 +57,26 @@ class LargeDBTests {
}
@Test
fun insertHeavyEvent() {
events.first { it.id == "3f34b8cb682307ec11753de4669ce8948e95fd6fb360d79136446c5547fd235e" }.let { event ->
try {
db.insert(event)
} catch (e: SQLiteException) {
Log.w("LargeDBTests") { "Error inserting event: ${e.message} for event: ${event.toJson()}" }
fun insertHeavyEvent() =
runBlocking {
events.first { it.id == "3f34b8cb682307ec11753de4669ce8948e95fd6fb360d79136446c5547fd235e" }.let { event ->
try {
db.insert(event)
} catch (e: SQLiteException) {
Log.w("LargeDBTests") { "Error inserting event: ${e.message} for event: ${event.toJson()}" }
}
}
}
}
@Test
fun insertDatabase() {
events.forEach { event ->
try {
db.insert(event)
} catch (e: SQLiteException) {
Log.w("LargeDBTests") { "Error inserting event: ${e.message} for event: ${event.toJson()}" }
fun insertDatabase() =
runBlocking {
events.forEach { event ->
try {
db.insert(event)
} catch (e: SQLiteException) {
Log.w("LargeDBTests") { "Error inserting event: ${e.message} for event: ${event.toJson()}" }
}
}
}
}
}
@@ -37,9 +37,9 @@ class QueryAssemblerTest : BaseDBTest() {
val key2 = "f3ac434d61bc0f491a814782ccfdf9c439dae1f0bde9097ad4a245f4c495cd14"
val key3 = "12ae0fd81c85e1e7d9ed096397dc3129849425fe6f8afce7213ebf38ddfc6ca9"
fun EventStore.explain(f: Filter) = store.queryBuilder.planQuery(f, hasher, store.connection)
suspend fun EventStore.explain(f: Filter) = store.pool.useReader { store.queryBuilder.planQuery(f, hasher, it) }
fun EventStore.explain(f: List<Filter>) = store.queryBuilder.planQuery(f, hasher, store.connection)
suspend fun EventStore.explain(f: List<Filter>) = store.pool.useReader { store.queryBuilder.planQuery(f, hasher, it) }
@Test
fun testEmpty() =