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
This commit is contained in:
Claude
2026-04-26 13:49:47 +00:00
parent 9fcf85bed0
commit 3bf1448d63
3 changed files with 60 additions and 6 deletions
@@ -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()
}
@@ -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,
@@ -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