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:
+4
-4
@@ -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,
|
||||
) {
|
||||
|
||||
+8
-4
@@ -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"))
|
||||
|
||||
+17
-14
@@ -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()}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -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() =
|
||||
|
||||
Reference in New Issue
Block a user