From 3bf1448d6396d03b4166476c3a8f9c3d2da406f8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 26 Apr 2026 13:49:47 +0000 Subject: [PATCH] docs(quartz/store): explain the connection pool and the suspend API - Add a Concurrency section to the SQLite store README covering the Room-style 1-writer + N-reader pool, the in-memory degradation, and the non-reentrant Mutex contract. - Refresh the SQLite "How to Use" examples to call out the suspend context and recommend transaction-batching for hot inserts. - Switch the ExpirationWorker example from Worker to CoroutineWorker now that deleteExpiredEvents is suspend. - Note in the FS README that the IEventStore API is suspend even though the FS layer keeps a synchronous flock manager (the withWriteLock helper is inline so suspend bodies pass through). - Update the FsMaintenanceTest description to match the coroutine-based concurrency test. - Document the Mutex non-reentrancy footgun in SQLiteConnectionPool's KDoc so module logic doesn't try to re-enter the pool from inside useWriter. https://claude.ai/code/session_016b5kSSbtDS3Ead6pN3Xqt5 --- .../quartz/nip01Core/store/sqlite/README.md | 53 +++++++++++++++++-- .../store/sqlite/SQLiteConnectionPool.kt | 6 +++ .../quartz/nip01Core/store/fs/README.md | 7 ++- 3 files changed, 60 insertions(+), 6 deletions(-) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/README.md b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/README.md index 3c3d5c6101..afac612fcf 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/README.md +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/README.md @@ -80,10 +80,39 @@ store.query( ) ``` +## Concurrency + +`androidx.sqlite.SQLiteConnection` is not thread-safe — same contract as +`sqlite3*` in the C API. To support concurrent inserts and reads from +multiple coroutines, `SQLiteEventStore` owns a Room-style +[`SQLiteConnectionPool`](SQLiteConnectionPool.kt): + +- **One writer connection**, guarded by a coroutine `Mutex`. SQLite only + allows one writer at the file level anyway, so serialising here costs + nothing — it just queues callers cooperatively instead of crashing + them on `BEGIN IMMEDIATE`. +- **N reader connections** (default 4), handed out from a `Channel` that + doubles as a semaphore. Under WAL (`PRAGMA journal_mode = WAL`) + readers run in parallel with the writer and with each other. + +For in-memory databases (`dbName == null`) the pool degrades to a +single shared connection — every fresh `:memory:` connection would +otherwise be a *separate* DB. Writes still serialise correctly; reads +just take the same writer mutex. + +The whole public API on `EventStore` / `SQLiteEventStore` is therefore +`suspend`. Callers must be in a coroutine; on Android, schedule +maintenance work as a `CoroutineWorker`. + +`Mutex` is non-reentrant: do not call `eventStore.query(...)` from +inside a `transaction { ... }` body. The transaction body itself +already holds the writer connection — query against the +`SQLiteConnection` handed to your block instead. + ## How to Use The `EventStore` class provides a high-level interface for interacting with the event database. -It is initialized with a `SQLiteDatabase` instance, and it manages the underlying tables and query planning. +It owns the underlying [`SQLiteConnectionPool`](SQLiteConnectionPool.kt) and the query planner. ### Initialization @@ -95,7 +124,7 @@ val eventStore = EventStore("dbname.db", relayUrlIdentifier) ### Querying Events -To query events, use the `query` method with one or more `Filter` objects: +To query events, use the `query` method with one or more `Filter` objects (in a coroutine): ```kotlin val filters = listOf( @@ -129,6 +158,18 @@ Insert a single event using the `insert` method: eventStore.insert(event) ``` +For batch inserts, prefer a single `transaction` — one `BEGIN`/`COMMIT` +per batch is roughly an order of magnitude faster on WAL than one per +event: + +```kotlin +eventStore.transaction { + insert(event1) + insert(event2) + insert(event3) +} +``` + ### Deleting Events Events should be deleted by adding a DeletionRequest or a VanishRequest to the db, but to manually @@ -154,11 +195,13 @@ The store exposes a `deleteExpiredEvents` to be used in a periodic clean up proc should use a WorkManager or a coroutine to periodically call `store.deleteExpiredEvents()`. We recommend a 15-minute window to remove recently expired events from the database. -Here's an example of a Worker that should be added to your application class. +Here's an example of a Worker that should be added to your application class. Use +`CoroutineWorker` (not `Worker`) — `deleteExpiredEvents()` is a `suspend` function. ```kotlin -class ExpirationWorker(appContext: Context, workerParams: WorkerParameters) : Worker(appContext, workerParams) { - override fun doWork(): Result { +class ExpirationWorker(appContext: Context, workerParams: WorkerParameters) : + CoroutineWorker(appContext, workerParams) { + override suspend fun doWork(): Result { YourApplication.store.deleteExpiredEvents() return Result.success() } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SQLiteConnectionPool.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SQLiteConnectionPool.kt index e5adc07019..5ca19d9857 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SQLiteConnectionPool.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SQLiteConnectionPool.kt @@ -59,6 +59,12 @@ import kotlinx.coroutines.sync.withLock * per-connection in SQLite — `journal_mode=WAL` is the only * database-wide one; subsequent connections inherit it). * 3. [close] drains the reader channel and closes every connection. + * + * Reentrancy: [Mutex] is **not** reentrant — calling [useWriter] (or, on + * an in-memory DB, [useReader]) from inside an already-acquired + * [useWriter] block deadlocks. Module logic that runs under [useWriter] + * (e.g. `innerInsertEvent`) must operate on the `SQLiteConnection` + * handed to its block; it must not re-enter the pool. */ class SQLiteConnectionPool( val driver: SQLiteDriver, diff --git a/quartz/src/jvmMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/README.md b/quartz/src/jvmMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/README.md index 15515c1cfa..7715638b80 100644 --- a/quartz/src/jvmMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/README.md +++ b/quartz/src/jvmMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/README.md @@ -128,6 +128,11 @@ lock — atomic-rename writes mean readers see either the pre- or post-mutation state, and `NoSuchFileException` on a just-unlinked candidate is silently skipped. +The `IEventStore` API is `suspend`. The flock manager itself is +synchronous (`ReentrantLock` + `FileChannel.lock`); each suspend +public method just brackets its work in `lockManager.withWriteLock { +... }`, which is `inline` so suspend bodies pass through. + ## Usage ### Initialisation @@ -301,7 +306,7 @@ Tests live under | `FsExpirationTest` | NIP-40 future / past / equal-now / sweep / non-positive | | `FsVanishTest` | NIP-62 cascade / block / strongest-cutoff-wins / per-relay scoping | | `FsSearchTest` | tokenizer behaviour, single-token / AND-of-tokens, ordering, reopen | -| `FsMaintenanceTest` | flock, transaction commit + propagated exceptions, re-entrant lock, scrub, compact, two-thread concurrency | +| `FsMaintenanceTest` | flock, transaction commit + propagated exceptions, re-entrant lock, scrub, compact, concurrent inserts from multiple coroutines on `Dispatchers.IO` | | `FsParityTest` | drive both this store and SQLite with identical streams and assert results match | ```bash