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
@@ -173,7 +173,7 @@ class Context(
* publish from", which mirrors `User.outboxRelays()` in the
* Android app.
*/
fun outboxRelays(): Set<NormalizedRelayUrl> =
suspend fun outboxRelays(): Set<NormalizedRelayUrl> =
relaysOf(identity.pubKeyHex)?.writeRelaysNorm()?.takeIf { it.isNotEmpty() }?.toSet()
?: DefaultNIP65RelaySet
@@ -181,7 +181,7 @@ class Context(
* DM inbox relays (NIP-17 kind:10050) for this account. Falls back
* to [DefaultDMRelayList] when no kind:10050 has been seen.
*/
fun inboxRelays(): Set<NormalizedRelayUrl> =
suspend fun inboxRelays(): Set<NormalizedRelayUrl> =
dmInboxOf(identity.pubKeyHex)?.relays()?.takeIf { it.isNotEmpty() }?.toSet()
?: DefaultDMRelayList.toSet()
@@ -190,12 +190,12 @@ class Context(
* back to [outboxRelays] when no kind:10051 has been seen — same
* fallback the Android app uses for KeyPackage discovery.
*/
fun keyPackageRelays(): Set<NormalizedRelayUrl> =
suspend fun keyPackageRelays(): Set<NormalizedRelayUrl> =
keyPackageRelaysOf(identity.pubKeyHex)?.relays()?.takeIf { it.isNotEmpty() }?.toSet()
?: outboxRelays()
/** Union of all three buckets. */
fun anyRelays(): Set<NormalizedRelayUrl> = outboxRelays() + inboxRelays() + keyPackageRelays()
suspend fun anyRelays(): Set<NormalizedRelayUrl> = outboxRelays() + inboxRelays() + keyPackageRelays()
/**
* Seed relays for "look up someone we know nothing about" queries —
@@ -208,7 +208,7 @@ class Context(
* most reliable place to find a stranger's replaceable events even when
* we and they have completely disjoint relay configurations.
*/
fun bootstrapRelays(): Set<NormalizedRelayUrl> =
suspend fun bootstrapRelays(): Set<NormalizedRelayUrl> =
buildSet {
addAll(anyRelays())
addAll(DefaultNIP65RelaySet)
@@ -319,7 +319,7 @@ class Context(
* Every event-arrival path in the CLI funnels through this method
* so that [store] is the authoritative cache of what Amy has seen.
*/
fun verifyAndStore(event: Event): Boolean {
suspend fun verifyAndStore(event: Event): Boolean {
if (!event.verify()) {
System.err.println("[cli] dropped event ${event.id.take(8)} kind=${event.kind} — bad signature")
return false
@@ -342,7 +342,7 @@ class Context(
* this user. Callers that need a network fetch on miss should fall
* back to [drain] explicitly — this helper never hits the network.
*/
fun profileOf(pubKey: HexKey): MetadataEvent? =
suspend fun profileOf(pubKey: HexKey): MetadataEvent? =
store
.query<Event>(
Filter(authors = listOf(pubKey), kinds = listOf(MetadataEvent.KIND), limit = 1),
@@ -352,7 +352,7 @@ class Context(
* Latest known kind:10002 advertised relay list (NIP-65) for
* [pubKey]. `null` when Amy has never seen one.
*/
fun relaysOf(pubKey: HexKey): AdvertisedRelayListEvent? =
suspend fun relaysOf(pubKey: HexKey): AdvertisedRelayListEvent? =
store
.query<Event>(
Filter(authors = listOf(pubKey), kinds = listOf(AdvertisedRelayListEvent.KIND), limit = 1),
@@ -363,7 +363,7 @@ class Context(
* `null` if Amy has never observed one. Useful for follow-graph
* lookups without re-hitting relays.
*/
fun contactsOf(pubKey: HexKey): ContactListEvent? =
suspend fun contactsOf(pubKey: HexKey): ContactListEvent? =
store
.query<Event>(
Filter(authors = listOf(pubKey), kinds = listOf(ContactListEvent.KIND), limit = 1),
@@ -374,7 +374,7 @@ class Context(
* for [pubKey], or `null` if Amy has never observed one. Used by
* `dm send` to resolve where to deliver a wrap.
*/
fun dmInboxOf(pubKey: HexKey): ChatMessageRelayListEvent? =
suspend fun dmInboxOf(pubKey: HexKey): ChatMessageRelayListEvent? =
store
.query<Event>(
Filter(authors = listOf(pubKey), kinds = listOf(ChatMessageRelayListEvent.KIND), limit = 1),
@@ -386,7 +386,7 @@ class Context(
* `marmot key-package check` and `marmot await key-package` to
* locate where the recipient publishes their KeyPackages.
*/
fun keyPackageRelaysOf(pubKey: HexKey): KeyPackageRelayListEvent? =
suspend fun keyPackageRelaysOf(pubKey: HexKey): KeyPackageRelayListEvent? =
store
.query<Event>(
Filter(authors = listOf(pubKey), kinds = listOf(KeyPackageRelayListEvent.KIND), limit = 1),
@@ -405,7 +405,7 @@ class Context(
* we'll still hand back the old list. Commands that care can drain
* (which re-populates the cache) or expose a `--refresh` flag.
*/
fun cachedRelayListsOf(pubKey: HexKey): RecipientRelayFetcher.Lists? {
suspend fun cachedRelayListsOf(pubKey: HexKey): RecipientRelayFetcher.Lists? {
val dm = dmInboxOf(pubKey)
val kp = keyPackageRelaysOf(pubKey)
val nip65 = relaysOf(pubKey)
@@ -142,7 +142,7 @@ object FeedCommand {
* an arbitrary `--author` we have no idea where they publish, so we
* widen to the bootstrap union.
*/
private fun relaysForReadingFeed(
private suspend fun relaysForReadingFeed(
ctx: Context,
mode: String,
): Set<NormalizedRelayUrl> =
@@ -226,7 +226,7 @@ object ProfileCommands {
* else's profile, fall back to the bootstrap union so we still find a
* kind:0 even when our relay set and theirs are disjoint.
*/
private fun relaysForReadingProfile(
private suspend fun relaysForReadingProfile(
ctx: Context,
isSelf: Boolean,
): Set<NormalizedRelayUrl> =
@@ -137,7 +137,7 @@ object RelayCommands {
}
}
private fun list(dataDir: DataDir): Int {
private suspend fun list(dataDir: DataDir): Int {
val ctx = Context.open(dataDir)
try {
val self = ctx.identity.pubKeyHex
@@ -132,7 +132,7 @@ object StoreCommands {
return 0
}
private fun sweepExpired(dataDir: DataDir): Int =
private suspend fun sweepExpired(dataDir: DataDir): Int =
withStore(dataDir) { store ->
val expiresAtDir = dataDir.eventsDir.toPath().resolve("idx/expires_at")
val before = countEntries(expiresAtDir)
@@ -46,7 +46,7 @@ class LiveEventStore(
onBufferOverflow = BufferOverflow.DROP_LATEST, // Default behavior
)
fun insert(event: Event) {
suspend fun insert(event: Event) {
store.insert(event)
newEventStream.tryEmit(event)
}
@@ -70,5 +70,5 @@ class LiveEventStore(
}
}
fun count(filters: List<Filter>) = store.count(filters)
suspend fun count(filters: List<Filter>) = store.count(filters)
}
@@ -111,6 +111,36 @@ class RelaySession(
}
}
private suspend fun handleEvent(cmd: EventCmd) {
val result = policy.accept(cmd)
if (result is PolicyResult.Rejected) {
send(OkMessage(cmd.event.id, false, result.reason))
return
}
try {
store.insert(cmd.event)
send(OkMessage(cmd.event.id, true, ""))
} catch (e: Exception) {
send(OkMessage(cmd.event.id, false, e.message ?: e::class.simpleName ?: "unkown error"))
}
}
private suspend fun handleCount(cmd: CountCmd) {
val result = policy.accept(cmd)
if (result is PolicyResult.Rejected) {
send(ClosedMessage(cmd.queryId, result.reason))
return
}
// Policy may rewrite filters to match the user's access level.
val filters = (result as PolicyResult.Accepted).cmd.filters
val total = store.count(filters)
send(CountMessage(cmd.queryId, CountResult(total)))
}
// -- NIP-42: AUTH ---------------------------------------------------------
private fun handleAuth(cmd: AuthCmd) {
val result = policy.accept(cmd)
@@ -164,38 +194,6 @@ class RelaySession(
}
}
// -- NIP-01: EVENT --------------------------------------------------------
private fun handleEvent(cmd: EventCmd) {
val result = policy.accept(cmd)
if (result is PolicyResult.Rejected) {
send(OkMessage(cmd.event.id, false, result.reason))
return
}
try {
store.insert(cmd.event)
send(OkMessage(cmd.event.id, true, ""))
} catch (e: Exception) {
send(OkMessage(cmd.event.id, false, e.message ?: e::class.simpleName ?: "unkown error"))
}
}
// -- NIP-45: COUNT --------------------------------------------------------
private fun handleCount(cmd: CountCmd) {
val result = policy.accept(cmd)
if (result is PolicyResult.Rejected) {
send(ClosedMessage(cmd.queryId, result.reason))
return
}
// Policy may rewrite filters to match the user's access level.
val filters = (result as PolicyResult.Accepted).cmd.filters
val total = store.count(filters)
send(CountMessage(cmd.queryId, CountResult(total)))
}
init {
policy.onConnect(::send)
}
@@ -24,37 +24,37 @@ import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
interface IEventStore : AutoCloseable {
fun insert(event: Event)
suspend fun insert(event: Event)
interface ITransaction {
fun insert(event: Event)
}
fun transaction(body: ITransaction.() -> Unit)
suspend fun transaction(body: ITransaction.() -> Unit)
fun <T : Event> query(filter: Filter): List<T>
suspend fun <T : Event> query(filter: Filter): List<T>
fun <T : Event> query(filters: List<Filter>): List<T>
suspend fun <T : Event> query(filters: List<Filter>): List<T>
fun <T : Event> query(
suspend fun <T : Event> query(
filter: Filter,
onEach: (T) -> Unit,
)
fun <T : Event> query(
suspend fun <T : Event> query(
filters: List<Filter>,
onEach: (T) -> Unit,
)
fun count(filter: Filter): Int
suspend fun count(filter: Filter): Int
fun count(filters: List<Filter>): Int
suspend fun count(filters: List<Filter>): Int
fun delete(filter: Filter)
suspend fun delete(filter: Filter)
fun delete(filters: List<Filter>)
suspend fun delete(filters: List<Filter>)
fun deleteExpiredEvents()
suspend fun deleteExpiredEvents()
override fun close()
}
@@ -34,33 +34,37 @@ class EventStore(
) : IEventStore {
val store = SQLiteEventStore(BundledSQLiteDriver(), dbName, relay, indexStrategy)
override fun insert(event: Event) = store.insertEvent(event)
override suspend fun insert(event: Event) = store.insertEvent(event)
override fun transaction(body: IEventStore.ITransaction.() -> Unit) = store.transaction(body)
override suspend fun transaction(body: IEventStore.ITransaction.() -> Unit) = store.transaction(body)
override fun <T : Event> query(filter: Filter) = store.query<T>(filter)
override suspend fun <T : Event> query(filter: Filter) = store.query<T>(filter)
override fun <T : Event> query(filters: List<Filter>) = store.query<T>(filters)
override suspend fun <T : Event> query(filters: List<Filter>) = store.query<T>(filters)
override fun <T : Event> query(
override suspend fun <T : Event> query(
filter: Filter,
onEach: (T) -> Unit,
) = store.query(filter, onEach)
override fun <T : Event> query(
override suspend fun <T : Event> query(
filters: List<Filter>,
onEach: (T) -> Unit,
) = store.query(filters, onEach)
override fun count(filter: Filter) = store.count(filter)
override suspend fun count(filter: Filter) = store.count(filter)
override fun count(filters: List<Filter>) = store.count(filters)
override suspend fun count(filters: List<Filter>) = store.count(filters)
override fun delete(filter: Filter) = store.delete(filter)
override suspend fun delete(filter: Filter) {
store.delete(filter)
}
override fun delete(filters: List<Filter>) = store.delete(filters)
override suspend fun delete(filters: List<Filter>) {
store.delete(filters)
}
override fun deleteExpiredEvents() = store.deleteExpiredEvents()
override suspend fun deleteExpiredEvents() = store.deleteExpiredEvents()
override fun close() = store.connection.close()
override fun close() = store.close()
}
@@ -22,10 +22,10 @@ package com.vitorpamplona.quartz.nip01Core.store.sqlite
import androidx.sqlite.SQLiteConnection
fun SQLiteEventStore.explainQuery(
suspend fun SQLiteEventStore.explainQuery(
sql: String,
args: Array<Any> = emptyArray(),
) = connection.explainQuery(sql, args.map { it.toString() }.toTypedArray())
): String = pool.useReader { it.explainQuery(sql, args.map { a -> a.toString() }.toTypedArray()) }
fun SQLiteConnection.explainQuery(
sql: String,
@@ -0,0 +1,135 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip01Core.store.sqlite
import androidx.sqlite.SQLiteConnection
import androidx.sqlite.SQLiteDriver
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
/**
* Room-style connection pool for an `androidx.sqlite` database.
*
* `androidx.sqlite.SQLiteConnection` is not thread-safe (same contract as
* `sqlite3*` in the C API): a single connection may only be used by one
* thread at a time. Two coroutines hitting the same connection in parallel
* race on `BEGIN IMMEDIATE` and prepared-statement state, which surfaces
* as `SQLITE_ERROR: cannot start a transaction within a transaction` or
* `SQLITE_MISUSE`.
*
* The pool mirrors what Room does:
*
* - **One writer connection**, guarded by a coroutine [Mutex]. SQLite
* only allows a single writer at the file level anyway, so serialising
* writes here costs nothing — it just queues callers cooperatively
* instead of crashing them.
* - **N reader connections**, 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`) every fresh `:memory:`
* connection opens a *separate* database, so the pool degrades to a
* single-connection mode where readers also acquire the writer mutex.
* That still fixes the parallel-insert crash; it just sacrifices reader
* concurrency for an in-memory store.
*
* Lifecycle:
* 1. `init` opens the writer, runs [onConfigure] on it, then [onMigrate]
* so schema exists before any reader sees the file.
* 2. Readers are opened next and each gets [onConfigure] (PRAGMAs are
* 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.
*/
class SQLiteConnectionPool(
val driver: SQLiteDriver,
val dbName: String?,
val numReaders: Int = 4,
val onConfigure: (SQLiteConnection) -> Unit = {},
val onMigrate: (SQLiteConnection) -> Unit = {},
) : AutoCloseable {
private val isInMemory = dbName == null
private val writerMutex = Mutex()
val writer: SQLiteConnection
private val readers: List<SQLiteConnection>
private val readerChannel: Channel<SQLiteConnection>?
init {
writer = openConnection()
onMigrate(writer)
if (isInMemory) {
readers = emptyList()
readerChannel = null
} else {
readers = List(numReaders) { openConnection() }
readerChannel = Channel(numReaders)
readers.forEach { readerChannel.trySend(it) }
}
}
private fun openConnection(): SQLiteConnection {
val db = driver.open(dbName ?: ":memory:")
onConfigure(db)
return db
}
/**
* Acquire the writer connection for the duration of [block]. Other
* writers (and, in the in-memory single-connection mode, readers)
* suspend until the lock is released. Cancellation-aware via the
* coroutine [Mutex].
*/
suspend fun <T> useWriter(block: (SQLiteConnection) -> T): T =
writerMutex.withLock {
block(writer)
}
/**
* Acquire any free reader connection for [block]. With a file-backed
* DB up to [numReaders] readers run in parallel with the writer
* (WAL). With an in-memory DB this falls back to the writer mutex
* because each `:memory:` connection would be a separate database.
*/
suspend fun <T> useReader(block: (SQLiteConnection) -> T): T {
val ch =
readerChannel
?: return writerMutex.withLock { block(writer) }
val conn = ch.receive()
try {
return block(conn)
} finally {
// Capacity == numReaders and we own the conn we received, so
// trySend never fails unless the channel was closed mid-flight
// (in which case the connection is being torn down anyway).
ch.trySend(conn)
}
}
override fun close() {
readerChannel?.close()
readers.forEach { runCatching { it.close() } }
runCatching { writer.close() }
}
}
@@ -34,24 +34,18 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.store.IEventStore
import com.vitorpamplona.quartz.nip40Expiration.isExpired
import com.vitorpamplona.quartz.utils.EventFactory
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.IO
import kotlinx.coroutines.withContext
class SQLiteEventStore(
val driver: SQLiteDriver = BundledSQLiteDriver(),
val dbName: String? = "events.db",
val relay: NormalizedRelayUrl? = null,
val indexStrategy: IndexingStrategy = DefaultIndexingStrategy(),
val numReaders: Int = 4,
) {
companion object {
const val DATABASE_VERSION = 2
}
val connection: SQLiteConnection by lazy {
openAndConfigure()
}
val seedModule = SeedModule()
val fullTextSearchModule = FullTextSearchModule()
@@ -89,23 +83,30 @@ class SQLiteEventStore(
fullTextSearchModule,
)
private fun openAndConfigure(): SQLiteConnection {
val db = driver.open(dbName ?: ":memory:")
// 32MB memory cache
val pool: SQLiteConnectionPool by lazy {
SQLiteConnectionPool(
driver = driver,
dbName = dbName,
numReaders = numReaders,
onConfigure = { db ->
// 32MB memory cache (per-connection).
db.execSQL("PRAGMA cache_size=-32000;")
// makes sure the FKs are sane
// Make sure the FKs are sane (per-connection).
db.execSQL("PRAGMA foreign_keys = ON;")
// SQLite implements mutations by appending them to a log, which it occasionally
// compacts into the database. This is called Write-Ahead Logging (WAL)
// SQLite implements mutations by appending them to a log,
// which it occasionally compacts into the database. This
// is called Write-Ahead Logging (WAL). Setting it on the
// first connection is enough — `journal_mode` is
// database-wide; subsequent connections inherit it.
db.execSQL("PRAGMA journal_mode = WAL;")
// The DB can be corrupted if the OS is shutdown before sync, which generally
// doesn't happen on Android
// The DB can be corrupted if the OS shuts down before
// sync, which generally doesn't happen on Android.
db.execSQL("PRAGMA synchronous = OFF;")
},
onMigrate = { db ->
val currentVersion = getUserVersion(db)
if (currentVersion == 0) {
db.transaction {
@@ -118,8 +119,8 @@ class SQLiteEventStore(
setUserVersion(this, DATABASE_VERSION)
}
}
return db
},
)
}
private fun getUserVersion(db: SQLiteConnection): Int =
@@ -159,24 +160,23 @@ class SQLiteEventStore(
}
}
fun clearDB() {
modules.reversed().forEach { it.deleteAll(connection) }
suspend fun clearDB() =
pool.useWriter { db ->
modules.reversed().forEach { it.deleteAll(db) }
}
suspend fun vacuum() {
suspend fun vacuum() =
pool.useWriter { db ->
// VACUUM: Rebuilds the database file, reclaiming unused space
// and reducing fragmentation.
withContext(Dispatchers.IO) {
connection.execSQL("VACUUM")
}
db.execSQL("VACUUM")
}
suspend fun analyse() {
suspend fun analyse() =
pool.useWriter { db ->
// ANALYZE: Collects statistics about tables and indices
// to help the query planner optimize queries.
withContext(Dispatchers.IO) {
connection.execSQL("ANALYZE")
}
db.execSQL("ANALYZE")
}
private fun innerInsertEvent(
@@ -190,14 +190,16 @@ class SQLiteEventStore(
rightToVanishModule.insert(event, relay, headerId, db)
}
fun insertEvent(event: Event) {
suspend fun insertEvent(event: Event) {
if (event.isExpired()) throw SQLiteException("blocked: Cannot insert an expired event")
if (event.kind.isEphemeral()) return
connection.transaction {
pool.useWriter { db ->
db.transaction {
innerInsertEvent(event, this)
}
}
}
inner class Transaction(
val db: SQLiteConnection,
@@ -210,64 +212,65 @@ class SQLiteEventStore(
}
}
fun transaction(body: Transaction.() -> Unit) {
connection.transaction {
suspend fun transaction(body: Transaction.() -> Unit) {
pool.useWriter { db ->
db.transaction {
with(Transaction(this)) {
body()
}
}
}
}
fun <T : Event> query(filter: Filter): List<T> = queryBuilder.query(filter, connection)
suspend fun <T : Event> query(filter: Filter): List<T> = pool.useReader { queryBuilder.query(filter, it) }
fun <T : Event> query(filters: List<Filter>): List<T> = queryBuilder.query(filters, connection)
suspend fun <T : Event> query(filters: List<Filter>): List<T> = pool.useReader { queryBuilder.query(filters, it) }
fun <T : Event> query(
suspend fun <T : Event> query(
filter: Filter,
onEach: (T) -> Unit,
) = queryBuilder.query(filter, connection, onEach)
) = pool.useReader { queryBuilder.query(filter, it, onEach) }
fun <T : Event> query(
suspend fun <T : Event> query(
filters: List<Filter>,
onEach: (T) -> Unit,
) = queryBuilder.query(filters, connection, onEach)
) = pool.useReader { queryBuilder.query(filters, it, onEach) }
fun rawQuery(filter: Filter): List<RawEvent> = queryBuilder.rawQuery(filter, connection)
suspend fun rawQuery(filter: Filter): List<RawEvent> = pool.useReader { queryBuilder.rawQuery(filter, it) }
fun rawQuery(filters: List<Filter>): List<RawEvent> = queryBuilder.rawQuery(filters, connection)
suspend fun rawQuery(filters: List<Filter>): List<RawEvent> = pool.useReader { queryBuilder.rawQuery(filters, it) }
fun rawQuery(
suspend fun rawQuery(
filter: Filter,
onEach: (RawEvent) -> Unit,
) = queryBuilder.rawQuery(filter, connection, onEach)
) = pool.useReader { queryBuilder.rawQuery(filter, it, onEach) }
fun rawQuery(
suspend fun rawQuery(
filters: List<Filter>,
onEach: (RawEvent) -> Unit,
) = queryBuilder.rawQuery(filters, connection, onEach)
) = pool.useReader { queryBuilder.rawQuery(filters, it, onEach) }
fun planQuery(filter: Filter) = queryBuilder.planQuery(filter, seedModule.hasher(connection), connection)
suspend fun planQuery(filter: Filter) = pool.useReader { queryBuilder.planQuery(filter, seedModule.hasher(it), it) }
fun planQuery(filters: List<Filter>) = queryBuilder.planQuery(filters, seedModule.hasher(connection), connection)
suspend fun planQuery(filters: List<Filter>) = pool.useReader { queryBuilder.planQuery(filters, seedModule.hasher(it), it) }
fun count(filter: Filter): Int = queryBuilder.count(filter, connection)
suspend fun count(filter: Filter): Int = pool.useReader { queryBuilder.count(filter, it) }
fun count(filters: List<Filter>): Int = queryBuilder.count(filters, connection)
suspend fun count(filters: List<Filter>): Int = pool.useReader { queryBuilder.count(filters, it) }
fun delete(filter: Filter) {
queryBuilder.delete(filter, connection)
suspend fun delete(filter: Filter) = pool.useWriter { queryBuilder.delete(filter, it) }
suspend fun delete(filters: List<Filter>) = pool.useWriter { queryBuilder.delete(filters, it) }
suspend fun delete(id: HexKey): Int =
pool.useWriter { db ->
db.execSQL("DELETE FROM event_headers WHERE id = ?", arrayOf(id))
db.changes()
}
fun delete(filters: List<Filter>) {
queryBuilder.delete(filters, connection)
}
suspend fun deleteExpiredEvents() = pool.useWriter { expirationModule.deleteExpiredEvents(it) }
fun delete(id: HexKey): Int {
connection.execSQL("DELETE FROM event_headers WHERE id = ?", arrayOf(id))
return connection.changes()
}
fun deleteExpiredEvents() = expirationModule.deleteExpiredEvents(connection)
fun close() = pool.close()
}
class RawEvent(
@@ -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".
// 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(db.store.connection) }
db.store.modules.forEach { it.create(db.store.connection) }
.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,7 +57,8 @@ class LargeDBTests {
}
@Test
fun insertHeavyEvent() {
fun insertHeavyEvent() =
runBlocking {
events.first { it.id == "3f34b8cb682307ec11753de4669ce8948e95fd6fb360d79136446c5547fd235e" }.let { event ->
try {
db.insert(event)
@@ -67,7 +69,8 @@ class LargeDBTests {
}
@Test
fun insertDatabase() {
fun insertDatabase() =
runBlocking {
events.forEach { event ->
try {
db.insert(event)
@@ -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() =
@@ -99,7 +99,7 @@ open class FsEventStore(
// Insert
// ------------------------------------------------------------------
override fun insert(event: Event) =
override suspend fun insert(event: Event) =
lockManager.withWriteLock {
insertLocked(event)
}
@@ -263,7 +263,7 @@ open class FsEventStore(
}
}
override fun transaction(body: IEventStore.ITransaction.() -> Unit) =
override suspend fun transaction(body: IEventStore.ITransaction.() -> Unit) =
lockManager.withWriteLock {
val txn =
object : IEventStore.ITransaction {
@@ -277,13 +277,13 @@ open class FsEventStore(
// ------------------------------------------------------------------
@Suppress("UNCHECKED_CAST")
override fun <T : Event> query(filter: Filter): List<T> {
override suspend fun <T : Event> query(filter: Filter): List<T> {
val out = mutableListOf<T>()
query<T>(filter) { out.add(it) }
return out
}
override fun <T : Event> query(filters: List<Filter>): List<T> {
override suspend fun <T : Event> query(filters: List<Filter>): List<T> {
val seen = HashSet<HexKey>()
val out = mutableListOf<T>()
filters.forEach { f ->
@@ -292,7 +292,7 @@ open class FsEventStore(
return out
}
override fun <T : Event> query(
override suspend fun <T : Event> query(
filter: Filter,
onEach: (T) -> Unit,
) {
@@ -311,7 +311,7 @@ open class FsEventStore(
}
}
override fun <T : Event> query(
override suspend fun <T : Event> query(
filters: List<Filter>,
onEach: (T) -> Unit,
) {
@@ -321,13 +321,13 @@ open class FsEventStore(
}
}
override fun count(filter: Filter): Int {
override suspend fun count(filter: Filter): Int {
var n = 0
query<Event>(filter) { n++ }
return n
}
override fun count(filters: List<Filter>): Int {
override suspend fun count(filters: List<Filter>): Int {
var n = 0
query<Event>(filters) { n++ }
return n
@@ -343,7 +343,7 @@ open class FsEventStore(
* entire store. This is asymmetric with `query(Filter())` which
* intentionally returns every event same contract as `SQLiteEventStore`.
*/
override fun delete(filter: Filter) =
override suspend fun delete(filter: Filter) =
lockManager.withWriteLock {
if (filter.isEmpty()) return@withWriteLock
val ids = ArrayList<HexKey>()
@@ -352,7 +352,7 @@ open class FsEventStore(
}
/** See [delete] for the empty-filter contract. */
override fun delete(filters: List<Filter>) =
override suspend fun delete(filters: List<Filter>) =
lockManager.withWriteLock {
val nonEmpty = filters.filterNot { it.isEmpty() }
if (nonEmpty.isEmpty()) return@withWriteLock
@@ -362,7 +362,7 @@ open class FsEventStore(
}
/** Delete an event by id. Returns 1 if a file was removed, 0 otherwise. */
fun delete(id: HexKey): Int =
suspend fun delete(id: HexKey): Int =
lockManager.withWriteLock {
deleteLocked(id)
}
@@ -408,7 +408,10 @@ open class FsEventStore(
if (parsed.first < event.createdAt) toDelete.add(parsed.second)
}
}
toDelete.forEach { delete(it) }
// Already inside the writer lock (insertLocked → processVanish);
// call the locked variant to avoid trying to re-suspend on the
// public `delete(id)` from a non-suspend body.
toDelete.forEach { deleteLocked(it) }
}
/**
@@ -416,7 +419,7 @@ open class FsEventStore(
* filenames, and deletes any entry whose `exp < now`. Matches SQLite's
* `expiration < unixepoch()` predicate (note: strict `<`, not `<=`).
*/
override fun deleteExpiredEvents() =
override suspend fun deleteExpiredEvents() =
lockManager.withWriteLock {
if (!Files.isDirectory(layout.idxExpiresAt)) return@withWriteLock
val now = now()
@@ -67,7 +67,7 @@ internal class FsLockManager(
}
}
fun <T> withWriteLock(body: () -> T): T {
fun acquireWriteLock() {
inProcessLock.lock()
try {
// Only the outermost re-entry actually touches the file lock.
@@ -83,18 +83,36 @@ internal class FsLockManager(
channel = ch
fileLock = l
}
} catch (t: Throwable) {
inProcessLock.unlock()
throw t
}
}
fun releaseWriteLock() {
try {
return body()
} finally {
if (inProcessLock.holdCount == 1) {
releaseFileLock()
}
}
} finally {
inProcessLock.unlock()
}
}
/**
* Inline so callers may invoke `suspend` functions inside the lock
* body needed by [FsEventStore.delete], which calls the suspend
* `query` to enumerate ids before deleting them.
*/
inline fun <T> withWriteLock(body: () -> T): T {
acquireWriteLock()
try {
return body()
} finally {
releaseWriteLock()
}
}
override fun close() {
inProcessLock.lock()
try {
@@ -26,6 +26,7 @@ import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
import com.vitorpamplona.quartz.utils.Secp256k1Instance
import kotlinx.coroutines.runBlocking
import java.nio.file.Files
import java.nio.file.Path
import kotlin.io.path.exists
@@ -86,7 +87,8 @@ class FsDeletionTest {
// ------------------------------------------------------------------
@Test
fun `kind-5 cascade-deletes a target by id`() {
fun `kind-5 cascade-deletes a target by id`() =
runBlocking {
val n1 = note("one", 10)
val n2 = note("two", 20)
store.insert(n1)
@@ -101,7 +103,8 @@ class FsDeletionTest {
}
@Test
fun `deletion blocks re-insertion of the same id`() {
fun `deletion blocks re-insertion of the same id`() =
runBlocking {
val n1 = note("one", 10)
store.insert(n1)
@@ -113,7 +116,8 @@ class FsDeletionTest {
}
@Test
fun `deletion by non-author neither cascades nor blocks legitimate re-insertion`() {
fun `deletion by non-author neither cascades nor blocks legitimate re-insertion`() =
runBlocking {
// other signer authors a note
val theirs = note("not yours", 10, signer = otherSigner)
store.insert(theirs)
@@ -141,7 +145,8 @@ class FsDeletionTest {
// ------------------------------------------------------------------
@Test
fun `kind-5 by address cascades addressable slot`() {
fun `kind-5 by address cascades addressable slot`() =
runBlocking {
val v1 = article("intro", "draft 1", 10)
val v2 = article("intro", "draft 2", 20)
store.insert(v1)
@@ -158,7 +163,8 @@ class FsDeletionTest {
}
@Test
fun `newer event at a deleted address may pass the cutoff`() {
fun `newer event at a deleted address may pass the cutoff`() =
runBlocking {
val v1 = article("intro", "draft 1", 10)
store.insert(v1)
@@ -174,7 +180,8 @@ class FsDeletionTest {
}
@Test
fun `older event at a deleted address is blocked by cutoff`() {
fun `older event at a deleted address is blocked by cutoff`() =
runBlocking {
val v1 = article("intro", "draft 1", 10)
store.insert(v1)
@@ -188,7 +195,8 @@ class FsDeletionTest {
}
@Test
fun `equal-timestamp event at a deleted address is blocked`() {
fun `equal-timestamp event at a deleted address is blocked`() =
runBlocking {
val v = article("intro", "v", 10)
store.insert(v)
@@ -205,7 +213,8 @@ class FsDeletionTest {
// ------------------------------------------------------------------
@Test
fun `later kind-5 raises the address cutoff`() {
fun `later kind-5 raises the address cutoff`() =
runBlocking {
val v = article("intro", "v", 10)
store.insert(v)
@@ -224,7 +233,8 @@ class FsDeletionTest {
}
@Test
fun `earlier kind-5 does not lower an existing stronger cutoff`() {
fun `earlier kind-5 does not lower an existing stronger cutoff`() =
runBlocking {
val v1 = article("slug", "v1", 10)
val v2 = article("slug", "v2", 20)
store.insert(v1)
@@ -254,7 +264,8 @@ class FsDeletionTest {
// ------------------------------------------------------------------
@Test
fun `deletion event itself is indexed and queryable`() {
fun `deletion event itself is indexed and queryable`() =
runBlocking {
val n = note("x", 10)
store.insert(n)
val del = signer.sign<DeletionEvent>(DeletionEvent.build(listOf(n), createdAt = 20))
@@ -269,7 +280,8 @@ class FsDeletionTest {
// ------------------------------------------------------------------
@Test
fun `non-author address deletion does not block legitimate addressable inserts`() {
fun `non-author address deletion does not block legitimate addressable inserts`() =
runBlocking {
// `otherSigner` (call them Bob) authors an addressable; the
// default `signer` (a stranger relative to Bob) then publishes a
// kind-5 with an `a` tag pointing at Bob's address. NIP-09 says
@@ -294,7 +306,8 @@ class FsDeletionTest {
}
@Test
fun `id tombstone is a hardlink to the kind-5 event`() {
fun `id tombstone is a hardlink to the kind-5 event`() =
runBlocking {
val n = note("x", 10)
store.insert(n)
val del = signer.sign<DeletionEvent>(DeletionEvent.build(listOf(n), createdAt = 20))
@@ -24,6 +24,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import com.vitorpamplona.quartz.utils.Secp256k1Instance
import kotlinx.coroutines.runBlocking
import java.nio.file.Files
import java.nio.file.Path
import kotlin.io.path.exists
@@ -63,7 +64,8 @@ class FsEventStoreTest {
}
@Test
fun `insert and query by id round-trips`() {
fun `insert and query by id round-trips`() =
runBlocking {
val note = signer.sign<TextNoteEvent>(TextNoteEvent.build("hello"))
store.insert(note)
@@ -76,7 +78,8 @@ class FsEventStoreTest {
}
@Test
fun `canonical path uses 2-char sharding`() {
fun `canonical path uses 2-char sharding`() =
runBlocking {
val note = signer.sign<TextNoteEvent>(TextNoteEvent.build("shard me"))
store.insert(note)
@@ -86,13 +89,15 @@ class FsEventStoreTest {
}
@Test
fun `query returns empty when nothing inserted`() {
fun `query returns empty when nothing inserted`() =
runBlocking {
val note = signer.sign<TextNoteEvent>(TextNoteEvent.build("missing"))
assertEquals(emptyList(), store.query<TextNoteEvent>(Filter(ids = listOf(note.id))))
}
@Test
fun `delete by id removes the file`() {
fun `delete by id removes the file`() =
runBlocking {
val note = signer.sign<TextNoteEvent>(TextNoteEvent.build("to-delete"))
store.insert(note)
assertEquals(1, store.count(Filter(ids = listOf(note.id))))
@@ -103,13 +108,15 @@ class FsEventStoreTest {
}
@Test
fun `delete returns 0 when event absent`() {
fun `delete returns 0 when event absent`() =
runBlocking {
val note = signer.sign<TextNoteEvent>(TextNoteEvent.build("never-inserted"))
assertEquals(0, store.delete(note.id))
}
@Test
fun `delete by filter with ids removes matching events`() {
fun `delete by filter with ids removes matching events`() =
runBlocking {
val a = signer.sign<TextNoteEvent>(TextNoteEvent.build("a"))
val b = signer.sign<TextNoteEvent>(TextNoteEvent.build("b"))
store.insert(a)
@@ -122,7 +129,8 @@ class FsEventStoreTest {
}
@Test
fun `insert of duplicate id is a no-op`() {
fun `insert of duplicate id is a no-op`() =
runBlocking {
val note = signer.sign<TextNoteEvent>(TextNoteEvent.build("dup"))
store.insert(note)
store.insert(note) // must not throw; content is immutable anyway
@@ -130,7 +138,8 @@ class FsEventStoreTest {
}
@Test
fun `ephemeral events are not persisted`() {
fun `ephemeral events are not persisted`() =
runBlocking {
// Kind 20_000 is the lowest ephemeral kind; use a bare Event
// constructed inline because TextNoteEvent pins kind=1.
val ephemeral =
@@ -145,7 +154,8 @@ class FsEventStoreTest {
}
@Test
fun `ids that share the same 4-char shard both persist`() {
fun `ids that share the same 4-char shard both persist`() =
runBlocking {
// Find two real events whose ids share the same first 4 hex chars.
// With a random KeyPair per sign, this takes a handful of tries.
var a = signer.sign<TextNoteEvent>(TextNoteEvent.build("a0", createdAt = 1))
@@ -170,7 +180,8 @@ class FsEventStoreTest {
}
@Test
fun `delete with empty filter is safe`() {
fun `delete with empty filter is safe`() =
runBlocking {
val a = signer.sign<TextNoteEvent>(TextNoteEvent.build("a", createdAt = 1))
val b = signer.sign<TextNoteEvent>(TextNoteEvent.build("b", createdAt = 2))
store.insert(a)
@@ -187,7 +198,8 @@ class FsEventStoreTest {
}
@Test
fun `staging dir is cleared on init`() {
fun `staging dir is cleared on init`() =
runBlocking {
val staging = root.resolve(".staging")
val leftover = Files.createTempFile(staging, "crash-", ".json")
assertTrue(leftover.exists())
@@ -26,6 +26,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import com.vitorpamplona.quartz.utils.Secp256k1Instance
import kotlinx.coroutines.runBlocking
import java.nio.file.Files
import java.nio.file.Path
import kotlin.io.path.exists
@@ -54,7 +55,8 @@ class FsEventToJsonTest {
}
@Test
fun `default formatter writes compact JSON one line`() {
fun `default formatter writes compact JSON one line`() =
runBlocking {
val store = FsEventStore(root)
try {
val n =
@@ -77,7 +79,8 @@ class FsEventToJsonTest {
}
@Test
fun `pretty formatter writes multi-line indented JSON and round-trips`() {
fun `pretty formatter writes multi-line indented JSON and round-trips`() =
runBlocking {
val store =
FsEventStore(
root,
@@ -24,6 +24,7 @@ import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
import com.vitorpamplona.quartz.utils.Secp256k1Instance
import kotlinx.coroutines.runBlocking
import java.nio.file.Files
import java.nio.file.Path
import kotlin.io.path.exists
@@ -81,7 +82,8 @@ class FsExpirationTest {
)
@Test
fun `event with future expiration is accepted and indexed`() {
fun `event with future expiration is accepted and indexed`() =
runBlocking {
clockNow = 1_000
val e = expiringNote("future", createdAt = 500, expiresAt = 2_000)
store.insert(e)
@@ -96,7 +98,8 @@ class FsExpirationTest {
}
@Test
fun `event already expired at insert time is rejected`() {
fun `event already expired at insert time is rejected`() =
runBlocking {
clockNow = 5_000
val e = expiringNote("dead-on-arrival", createdAt = 1_000, expiresAt = 4_000)
store.insert(e)
@@ -106,7 +109,8 @@ class FsExpirationTest {
}
@Test
fun `event with expiration equal to now is rejected (parity with SQLite trigger)`() {
fun `event with expiration equal to now is rejected (parity with SQLite trigger)`() =
runBlocking {
clockNow = 5_000
val e = expiringNote("just-now", createdAt = 1_000, expiresAt = 5_000)
store.insert(e)
@@ -115,7 +119,8 @@ class FsExpirationTest {
}
@Test
fun `non-positive expiration is ignored`() {
fun `non-positive expiration is ignored`() =
runBlocking {
clockNow = 5_000
val zero = expiringNote("zero", createdAt = 1, expiresAt = 0)
val neg = expiringNote("neg", createdAt = 2, expiresAt = -1)
@@ -130,7 +135,8 @@ class FsExpirationTest {
}
@Test
fun `deleteExpiredEvents sweeps everything past now`() {
fun `deleteExpiredEvents sweeps everything past now`() =
runBlocking {
clockNow = 1_000
val a = expiringNote("a", createdAt = 100, expiresAt = 500) // already expired
val b = expiringNote("b", createdAt = 200, expiresAt = 999) // expired in past
@@ -152,7 +158,8 @@ class FsExpirationTest {
}
@Test
fun `sweep uses strict less-than parity with SQLite`() {
fun `sweep uses strict less-than parity with SQLite`() =
runBlocking {
// SQLite trigger: WHERE NEW.expiration <= unixepoch() (insert)
// SQLite sweep: WHERE expiration < unixepoch() (delete)
// Insert-time uses inclusive <=, sweep uses strict <.
@@ -170,7 +177,8 @@ class FsExpirationTest {
}
@Test
fun `sweep removes index entries too`() {
fun `sweep removes index entries too`() =
runBlocking {
clockNow = 50
val e = expiringNote("x", createdAt = 1, expiresAt = 100)
store.insert(e)
@@ -188,7 +196,8 @@ class FsExpirationTest {
}
@Test
fun `events without expiration are unaffected by sweep`() {
fun `events without expiration are unaffected by sweep`() =
runBlocking {
clockNow = 100
val plain =
signer.sign<Event>(
@@ -24,6 +24,10 @@ import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import com.vitorpamplona.quartz.utils.Secp256k1Instance
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import java.nio.file.Files
import java.nio.file.Path
import kotlin.io.path.exists
@@ -65,7 +69,8 @@ class FsMaintenanceTest {
// ------------------------------------------------------------------
@Test
fun `lock file is created on open`() {
fun `lock file is created on open`() =
runBlocking {
assertTrue(root.resolve(".lock").exists())
}
@@ -74,7 +79,8 @@ class FsMaintenanceTest {
// ------------------------------------------------------------------
@Test
fun `transaction commits all inserts on success`() {
fun `transaction commits all inserts on success`() =
runBlocking {
val a = note("a", 1)
val b = note("b", 2)
val c = note("c", 3)
@@ -90,7 +96,8 @@ class FsMaintenanceTest {
}
@Test
fun `transaction propagates exceptions and stops processing`() {
fun `transaction propagates exceptions and stops processing`() =
runBlocking {
val a = note("a", 1)
val b = note("b", 2)
val c = note("c", 3)
@@ -114,16 +121,20 @@ class FsMaintenanceTest {
}
@Test
fun `transaction is re-entrant on the same thread`() {
fun `transaction is re-entrant on the same thread`() =
runBlocking {
val a = note("a", 1)
val b = note("b", 2)
// If flock were non-reentrant we'd self-deadlock here because
// insert() acquires the same lock the transaction already holds.
store.transaction {
insert(a)
// Call an outer-locking method from within the transaction.
store.deleteExpiredEvents()
insert(b)
}
// And a follow-up suspend call also re-enters the lock cleanly.
store.deleteExpiredEvents()
assertEquals(1, store.count(Filter(ids = listOf(a.id))))
assertEquals(1, store.count(Filter(ids = listOf(b.id))))
}
// ------------------------------------------------------------------
@@ -131,7 +142,8 @@ class FsMaintenanceTest {
// ------------------------------------------------------------------
@Test
fun `scrub rebuilds idx entries after a manual wipe`() {
fun `scrub rebuilds idx entries after a manual wipe`() =
runBlocking {
val a = note("hello bitcoin", 10)
val b = note("nostr stuff", 20)
store.insert(a)
@@ -155,7 +167,8 @@ class FsMaintenanceTest {
}
@Test
fun `scrub leaves replaceable slot intact`() {
fun `scrub leaves replaceable slot intact`() =
runBlocking {
// Replaceable slots pin events via hardlink even without the
// canonical. Scrub must not wipe slots.
val meta =
@@ -178,7 +191,8 @@ class FsMaintenanceTest {
// ------------------------------------------------------------------
@Test
fun `compact drops idx entries whose canonical is gone`() {
fun `compact drops idx entries whose canonical is gone`() =
runBlocking {
val a = note("x", 10)
store.insert(a)
@@ -195,7 +209,8 @@ class FsMaintenanceTest {
}
@Test
fun `compact leaves valid entries alone`() {
fun `compact leaves valid entries alone`() =
runBlocking {
val a = note("x", 10)
store.insert(a)
@@ -211,13 +226,15 @@ class FsMaintenanceTest {
// ------------------------------------------------------------------
@Test
fun `close is idempotent`() {
fun `close is idempotent`() =
runBlocking {
store.close()
store.close()
}
@Test
fun `reopen after close works`() {
fun `reopen after close works`() =
runBlocking {
val a = note("a", 1)
store.insert(a)
store.close()
@@ -235,22 +252,21 @@ class FsMaintenanceTest {
// ------------------------------------------------------------------
@Test
fun `concurrent inserts on two threads are both persisted`() {
fun `concurrent inserts on two threads are both persisted`() =
runBlocking {
val events = (1..20).map { note("n$it", it.toLong()) }
val half = events.size / 2
val t1 =
Thread {
// Two real threads via Dispatchers.IO so the in-process lock has to
// arbitrate. join via coroutineScope.
coroutineScope {
launch(Dispatchers.IO) {
events.take(half).forEach { store.insert(it) }
}
val t2 =
Thread {
launch(Dispatchers.IO) {
events.drop(half).forEach { store.insert(it) }
}
t1.start()
t2.start()
t1.join()
t2.join()
}
val got = store.query<TextNoteEvent>(Filter(authors = listOf(signer.pubKey))).map { it.id }.toSet()
assertEquals(events.map { it.id }.toSet(), got)
@@ -29,6 +29,7 @@ import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
import com.vitorpamplona.quartz.utils.Secp256k1Instance
import kotlinx.coroutines.runBlocking
import java.nio.file.Files
import java.nio.file.Path
import kotlin.io.path.exists
@@ -81,7 +82,7 @@ class FsParityTest {
}
/** Insert into both stores. Swallow SQLite rejections (we only care about the resulting state). */
private fun insertBoth(event: Event) {
private suspend fun insertBoth(event: Event) {
try {
sqlite.insert(event)
} catch (_: Throwable) {
@@ -93,7 +94,7 @@ class FsParityTest {
}
/** Assert both stores return the same ids (as a set) for the given filter. */
private fun assertParity(
private suspend fun assertParity(
filter: Filter,
message: String = "",
) {
@@ -103,7 +104,7 @@ class FsParityTest {
}
/** Same, but expect a stable DESC-by-createdAt ordering. */
private fun assertParityOrdered(
private suspend fun assertParityOrdered(
filter: Filter,
message: String = "",
) {
@@ -135,25 +136,28 @@ class FsParityTest {
// ------------------------------------------------------------------
@Test
fun `id lookup matches`() {
fun `id lookup matches`() =
runBlocking {
val n = note("hello", 10)
insertBoth(n)
assertParity(Filter(ids = listOf(n.id)))
}
@Test
fun `kind + author query matches`() {
fun `kind + author query matches`() =
runBlocking {
val a = note("a", 1)
val b = note("b", 2)
val c = note("c", 3, s = otherSigner)
listOf(a, b, c).forEach(::insertBoth)
listOf(a, b, c).forEach { insertBoth(it) }
assertParityOrdered(Filter(kinds = listOf(1), authors = listOf(signer.pubKey)))
assertParityOrdered(Filter(authors = listOf(signer.pubKey, otherSigner.pubKey)))
}
@Test
fun `since until limit match`() {
fun `since until limit match`() =
runBlocking {
repeat(10) { i -> insertBoth(note("n$i", i.toLong() + 1)) }
assertParityOrdered(Filter(authors = listOf(signer.pubKey), since = 4, until = 8))
assertParityOrdered(Filter(authors = listOf(signer.pubKey), limit = 3))
@@ -164,7 +168,8 @@ class FsParityTest {
// ------------------------------------------------------------------
@Test
fun `single-letter tag queries match`() {
fun `single-letter tag queries match`() =
runBlocking {
val tagged =
signer.sign<Event>(
createdAt = 5,
@@ -185,7 +190,8 @@ class FsParityTest {
// ------------------------------------------------------------------
@Test
fun `replaceable newer wins parity`() {
fun `replaceable newer wins parity`() =
runBlocking {
val v1 =
signer.sign<Event>(
createdAt = 100,
@@ -208,7 +214,8 @@ class FsParityTest {
}
@Test
fun `replaceable older rejected parity`() {
fun `replaceable older rejected parity`() =
runBlocking {
val newer =
signer.sign<Event>(createdAt = 200, kind = 0, tags = emptyArray(), content = "{\"name\":\"new\"}")
val older =
@@ -220,7 +227,8 @@ class FsParityTest {
}
@Test
fun `addressable d-tag dedup parity`() {
fun `addressable d-tag dedup parity`() =
runBlocking {
val v1 = article("intro", "v1", 10)
val v2 = article("intro", "v2", 20)
val v3 = article("about", "bio", 15)
@@ -232,7 +240,8 @@ class FsParityTest {
}
@Test
fun `replaceable same-createdAt lexical id tiebreaker parity`() {
fun `replaceable same-createdAt lexical id tiebreaker parity`() =
runBlocking {
// Two kind-0 events with identical createdAt produce different ids
// because their content differs. NIP-01 says the lexically smaller
// id wins on a tie. Both stores must agree, regardless of insertion
@@ -266,7 +275,8 @@ class FsParityTest {
}
@Test
fun `addressable same-createdAt lexical id tiebreaker parity`() {
fun `addressable same-createdAt lexical id tiebreaker parity`() =
runBlocking {
val a = article("tie", "version a", 100)
val b = article("tie", "version b", 100)
@@ -288,7 +298,8 @@ class FsParityTest {
// ------------------------------------------------------------------
@Test
fun `deletion by id parity`() {
fun `deletion by id parity`() =
runBlocking {
val a = note("a", 10)
val b = note("b", 20)
insertBoth(a)
@@ -307,7 +318,8 @@ class FsParityTest {
}
@Test
fun `deletion by address parity`() {
fun `deletion by address parity`() =
runBlocking {
val v = article("intro", "v1", 10)
insertBoth(v)
@@ -328,7 +340,8 @@ class FsParityTest {
// ------------------------------------------------------------------
@Test
fun `expiration sweep parity`() {
fun `expiration sweep parity`() =
runBlocking {
// Build events with future-then-past expirations relative to now.
val now =
com.vitorpamplona.quartz.utils.TimeUtils
@@ -364,7 +377,8 @@ class FsParityTest {
// ------------------------------------------------------------------
@Test
fun `search parity`() {
fun `search parity`() =
runBlocking {
val a = note("hello bitcoin", 1)
val b = note("nostr only", 2)
val c = note("bitcoin and nostr", 3)
@@ -384,13 +398,14 @@ class FsParityTest {
// ------------------------------------------------------------------
@Test
fun `count parity across mixed stream`() {
fun `count parity across mixed stream`() =
runBlocking {
listOf(
note("a", 1),
note("b", 2),
note("c", 3),
note("from-other", 4, s = otherSigner),
).forEach(::insertBoth)
).forEach { insertBoth(it) }
val filter = Filter(authors = listOf(signer.pubKey))
assertEquals(sqlite.count(filter), fs.count(filter))
@@ -401,7 +416,8 @@ class FsParityTest {
// ------------------------------------------------------------------
@Test
fun `kitchen sink scenario`() {
fun `kitchen sink scenario`() =
runBlocking {
// Notes
val n1 = note("first", 1)
val n2 = note("second", 2)
@@ -417,7 +433,7 @@ class FsParityTest {
// Deletion of n1
val del = signer.sign<DeletionEvent>(DeletionEvent.build(listOf(n1), createdAt = 40))
listOf(n1, n2, meta1, meta2, artA, artB, artBv2, del).forEach(::insertBoth)
listOf(n1, n2, meta1, meta2, artA, artB, artBv2, del).forEach { insertBoth(it) }
// Snapshots that should match.
assertParity(Filter(ids = listOf(n1.id)), "n1 deleted")
@@ -432,7 +448,8 @@ class FsParityTest {
// ------------------------------------------------------------------
@Test
fun `multi-filter union parity`() {
fun `multi-filter union parity`() =
runBlocking {
val a = note("a", 1)
val b = note("b", 2, s = otherSigner)
insertBoth(a)
@@ -455,7 +472,8 @@ class FsParityTest {
// ------------------------------------------------------------------
@Test
fun `delete by filter parity`() {
fun `delete by filter parity`() =
runBlocking {
val toKill = note("dead", 5)
val survivor = note("alive", 6)
insertBoth(toKill)
@@ -472,13 +490,14 @@ class FsParityTest {
// ------------------------------------------------------------------
@Test
fun `helper sanity - empty stores agree`() {
fun `helper sanity - empty stores agree`() =
runBlocking {
assertParity(Filter(authors = listOf(signer.pubKey)))
assertParity(Filter(kinds = listOf(1)))
}
@Suppress("unused")
private fun debugDump(label: String): String {
private suspend fun debugDump(label: String): String {
val sqIds =
sqlite
.query<Event>(Filter(authors = listOf(signer.pubKey, otherSigner.pubKey)))
@@ -25,6 +25,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import com.vitorpamplona.quartz.utils.Secp256k1Instance
import kotlinx.coroutines.runBlocking
import java.nio.file.Files
import java.nio.file.Path
import kotlin.io.path.exists
@@ -71,18 +72,20 @@ class FsQueryTest {
// ------------------------------------------------------------------
@Test
fun `results ordered by created_at DESC`() {
fun `results ordered by created_at DESC`() =
runBlocking {
val a = signA("a", 1)
val b = signA("b", 3)
val c = signA("c", 2)
listOf(a, b, c).forEach(store::insert)
listOf(a, b, c).forEach { store.insert(it) }
val got = store.query<TextNoteEvent>(Filter(authors = listOf(signerA.pubKey)))
assertEquals(listOf(b.id, c.id, a.id), got.map { it.id })
}
@Test
fun `limit caps the result count`() {
fun `limit caps the result count`() =
runBlocking {
repeat(5) { i -> store.insert(signA("n$i", i.toLong() + 1)) }
val got = store.query<TextNoteEvent>(Filter(authors = listOf(signerA.pubKey), limit = 2))
assertEquals(2, got.size)
@@ -92,7 +95,8 @@ class FsQueryTest {
}
@Test
fun `limit of zero returns empty`() {
fun `limit of zero returns empty`() =
runBlocking {
store.insert(signA("x", 1))
assertEquals(emptyList(), store.query<TextNoteEvent>(Filter(authors = listOf(signerA.pubKey), limit = 0)))
}
@@ -102,7 +106,8 @@ class FsQueryTest {
// ------------------------------------------------------------------
@Test
fun `author filter isolates one user`() {
fun `author filter isolates one user`() =
runBlocking {
val a = signA("from-a", 1)
val b = signB("from-b", 2)
store.insert(a)
@@ -113,7 +118,8 @@ class FsQueryTest {
}
@Test
fun `author filter with multiple authors unions them`() {
fun `author filter with multiple authors unions them`() =
runBlocking {
val a = signA("a", 1)
val b = signB("b", 2)
store.insert(a)
@@ -124,7 +130,8 @@ class FsQueryTest {
}
@Test
fun `kind filter returns only the requested kinds`() {
fun `kind filter returns only the requested kinds`() =
runBlocking {
// Build two events of different kinds.
val note = signA("note", 1)
val ephemeralKinds = signerA.sign<Event>(createdAt = 2, kind = 30023, tags = arrayOf(arrayOf("d", "slug")), content = "article")
@@ -139,7 +146,8 @@ class FsQueryTest {
}
@Test
fun `kind + author intersect via post-filter`() {
fun `kind + author intersect via post-filter`() =
runBlocking {
val a = signA("a", 1)
val b = signB("b", 2)
store.insert(a)
@@ -154,7 +162,8 @@ class FsQueryTest {
// ------------------------------------------------------------------
@Test
fun `tag filter matches single-letter tags`() {
fun `tag filter matches single-letter tags`() =
runBlocking {
val tagged =
signerA.sign<Event>(
createdAt = 10,
@@ -171,18 +180,20 @@ class FsQueryTest {
}
@Test
fun `tag OR within key returns union`() {
fun `tag OR within key returns union`() =
runBlocking {
val t1 = signerA.sign<Event>(createdAt = 1, kind = 1, tags = arrayOf(arrayOf("t", "nostr")), content = "n")
val t2 = signerA.sign<Event>(createdAt = 2, kind = 1, tags = arrayOf(arrayOf("t", "bitcoin")), content = "b")
val t3 = signerA.sign<Event>(createdAt = 3, kind = 1, tags = arrayOf(arrayOf("t", "other")), content = "o")
listOf(t1, t2, t3).forEach(store::insert)
listOf(t1, t2, t3).forEach { store.insert(it) }
val got = store.query<Event>(Filter(tags = mapOf("t" to listOf("nostr", "bitcoin"))))
assertEquals(setOf(t1.id, t2.id), got.map { it.id }.toSet())
}
@Test
fun `tagsAll across keys requires all matches`() {
fun `tagsAll across keys requires all matches`() =
runBlocking {
val both =
signerA.sign<Event>(
createdAt = 1,
@@ -192,7 +203,7 @@ class FsQueryTest {
)
val onlyT = signerA.sign<Event>(createdAt = 2, kind = 1, tags = arrayOf(arrayOf("t", "nostr")), content = "t-only")
val onlyE = signerA.sign<Event>(createdAt = 3, kind = 1, tags = arrayOf(arrayOf("e", "a".repeat(64))), content = "e-only")
listOf(both, onlyT, onlyE).forEach(store::insert)
listOf(both, onlyT, onlyE).forEach { store.insert(it) }
val got =
store.query<Event>(
@@ -208,7 +219,8 @@ class FsQueryTest {
// ------------------------------------------------------------------
@Test
fun `safe ASCII tag values get raw directory names`() {
fun `safe ASCII tag values get raw directory names`() =
runBlocking {
val e =
signerA.sign<Event>(
createdAt = 10,
@@ -224,7 +236,8 @@ class FsQueryTest {
}
@Test
fun `pubkey p-tag uses raw 64-hex directory name`() {
fun `pubkey p-tag uses raw 64-hex directory name`() =
runBlocking {
// The motivating case: notifications. p-tags pointing at a
// pubkey land under idx/tag/p/<pubkey>/ — no hash, directly
// ls-able.
@@ -243,7 +256,8 @@ class FsQueryTest {
}
@Test
fun `tag value with emoji falls back to hashed directory name`() {
fun `tag value with emoji falls back to hashed directory name`() =
runBlocking {
val e =
signerA.sign<Event>(
createdAt = 10,
@@ -261,7 +275,8 @@ class FsQueryTest {
}
@Test
fun `tag value containing a slash falls back to hashed directory name`() {
fun `tag value containing a slash falls back to hashed directory name`() =
runBlocking {
val e =
signerA.sign<Event>(
createdAt = 10,
@@ -277,7 +292,8 @@ class FsQueryTest {
}
@Test
fun `query round-trips for both raw and hashed values`() {
fun `query round-trips for both raw and hashed values`() =
runBlocking {
// Each query must use the same naming rule as the writer or it
// walks a directory that doesn't exist. Insert both a raw-safe
// and a hash-required tag and verify they're both findable.
@@ -308,7 +324,8 @@ class FsQueryTest {
}
@Test
fun `non-single-letter tags are not reverse-indexed`() {
fun `non-single-letter tags are not reverse-indexed`() =
runBlocking {
// SQLite parity: DefaultIndexingStrategy only indexes single-letter
// tag names, so a tag-driven query for `mytag = foo` finds no
// candidates. The event is still persisted and can be fetched via
@@ -332,11 +349,12 @@ class FsQueryTest {
// ------------------------------------------------------------------
@Test
fun `since and until window filter`() {
fun `since and until window filter`() =
runBlocking {
val e1 = signA("t1", 100)
val e2 = signA("t2", 200)
val e3 = signA("t3", 300)
listOf(e1, e2, e3).forEach(store::insert)
listOf(e1, e2, e3).forEach { store.insert(it) }
val got = store.query<TextNoteEvent>(Filter(since = 150, until = 250))
assertEquals(listOf(e2.id), got.map { it.id })
@@ -347,7 +365,8 @@ class FsQueryTest {
// ------------------------------------------------------------------
@Test
fun `count matches query size`() {
fun `count matches query size`() =
runBlocking {
repeat(4) { i -> store.insert(signA("n$i", i.toLong() + 1)) }
val filter = Filter(authors = listOf(signerA.pubKey))
assertEquals(store.query<TextNoteEvent>(filter).size, store.count(filter))
@@ -358,7 +377,8 @@ class FsQueryTest {
// ------------------------------------------------------------------
@Test
fun `insert creates hardlinks in every expected index dir`() {
fun `insert creates hardlinks in every expected index dir`() =
runBlocking {
val tagged =
signerA.sign<Event>(
createdAt = 42,
@@ -380,7 +400,8 @@ class FsQueryTest {
}
@Test
fun `delete removes hardlinks so directories become empty`() {
fun `delete removes hardlinks so directories become empty`() =
runBlocking {
val e = signerA.sign<Event>(createdAt = 1, kind = 1, tags = arrayOf(arrayOf("t", "nostr")), content = "x")
store.insert(e)
store.delete(e.id)
@@ -405,7 +426,8 @@ class FsQueryTest {
// ------------------------------------------------------------------
@Test
fun `reopening the store preserves queryability`() {
fun `reopening the store preserves queryability`() =
runBlocking {
val tagged = signerA.sign<Event>(createdAt = 1, kind = 1, tags = arrayOf(arrayOf("t", "nostr")), content = "x")
store.insert(tagged)
store.close()
@@ -25,6 +25,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import com.vitorpamplona.quartz.utils.Secp256k1Instance
import kotlinx.coroutines.runBlocking
import java.nio.file.Files
import java.nio.file.Path
import kotlin.io.path.exists
@@ -66,25 +67,29 @@ class FsSearchTest {
// ------------------------------------------------------------------
@Test
fun `tokenizer splits on whitespace and punctuation`() {
fun `tokenizer splits on whitespace and punctuation`() =
runBlocking {
assertEquals(setOf("hello", "world"), FsSearchTokenizer.tokenize("hello, world!"))
}
@Test
fun `tokenizer is case insensitive`() {
fun `tokenizer is case insensitive`() =
runBlocking {
assertEquals(FsSearchTokenizer.tokenize("Bitcoin"), FsSearchTokenizer.tokenize("BITCOIN"))
assertEquals(FsSearchTokenizer.tokenize("Bitcoin"), FsSearchTokenizer.tokenize("bitcoin"))
}
@Test
fun `tokenizer handles empty and punctuation-only strings`() {
fun `tokenizer handles empty and punctuation-only strings`() =
runBlocking {
assertEquals(emptySet<String>(), FsSearchTokenizer.tokenize(""))
assertEquals(emptySet<String>(), FsSearchTokenizer.tokenize("..."))
assertEquals(emptySet<String>(), FsSearchTokenizer.tokenize(" "))
}
@Test
fun `tokenizer keeps unicode letters`() {
fun `tokenizer keeps unicode letters`() =
runBlocking {
assertEquals(setOf("café", "über"), FsSearchTokenizer.tokenize("Café Über"))
}
@@ -93,7 +98,8 @@ class FsSearchTest {
// ------------------------------------------------------------------
@Test
fun `searchable event creates one fts entry per unique token`() {
fun `searchable event creates one fts entry per unique token`() =
runBlocking {
val n = note("bitcoin nostr bitcoin", ts = 100)
store.insert(n)
@@ -109,7 +115,8 @@ class FsSearchTest {
}
@Test
fun `non-searchable event does not produce fts entries`() {
fun `non-searchable event does not produce fts entries`() =
runBlocking {
val meta =
signer.sign<MetadataEvent>(
createdAt = 1,
@@ -124,7 +131,8 @@ class FsSearchTest {
}
@Test
fun `delete removes fts entries`() {
fun `delete removes fts entries`() =
runBlocking {
val n = note("bitcoin nostr", ts = 100)
store.insert(n)
store.delete(n.id)
@@ -141,7 +149,8 @@ class FsSearchTest {
// ------------------------------------------------------------------
@Test
fun `single-token search returns the matching event`() {
fun `single-token search returns the matching event`() =
runBlocking {
val a = note("bitcoin is fun", ts = 1)
val b = note("nostr is also fun", ts = 2)
store.insert(a)
@@ -152,7 +161,8 @@ class FsSearchTest {
}
@Test
fun `multi-token search is AND across tokens`() {
fun `multi-token search is AND across tokens`() =
runBlocking {
val a = note("bitcoin only", ts = 1)
val b = note("nostr only", ts = 2)
val c = note("bitcoin and nostr", ts = 3)
@@ -165,7 +175,8 @@ class FsSearchTest {
}
@Test
fun `search results are ordered by createdAt DESC`() {
fun `search results are ordered by createdAt DESC`() =
runBlocking {
val older = note("bitcoin first", ts = 10)
val newer = note("bitcoin again", ts = 20)
store.insert(older)
@@ -176,14 +187,16 @@ class FsSearchTest {
}
@Test
fun `search respects limit`() {
fun `search respects limit`() =
runBlocking {
repeat(5) { i -> store.insert(note("bitcoin doc $i", ts = i.toLong() + 1)) }
val got = store.query<TextNoteEvent>(Filter(search = "bitcoin", limit = 2))
assertEquals(2, got.size)
}
@Test
fun `search composes with kinds and authors via post-filter`() {
fun `search composes with kinds and authors via post-filter`() =
runBlocking {
val match = note("bitcoin maximalism", ts = 5)
store.insert(match)
@@ -201,7 +214,8 @@ class FsSearchTest {
}
@Test
fun `search with no matching token returns empty`() {
fun `search with no matching token returns empty`() =
runBlocking {
store.insert(note("nostr only", ts = 1))
assertEquals(
emptyList(),
@@ -210,7 +224,8 @@ class FsSearchTest {
}
@Test
fun `blank search string is ignored`() {
fun `blank search string is ignored`() =
runBlocking {
val a = note("anything", ts = 1)
store.insert(a)
// Blank search shouldn't drive by FTS — the planner falls through
@@ -220,7 +235,8 @@ class FsSearchTest {
}
@Test
fun `search survives reopen`() {
fun `search survives reopen`() =
runBlocking {
val n = note("persistent token", ts = 100)
store.insert(n)
store.close()
@@ -239,7 +255,8 @@ class FsSearchTest {
// ------------------------------------------------------------------
@Test
fun `fts entry is unlinked when event is deleted`() {
fun `fts entry is unlinked when event is deleted`() =
runBlocking {
val n = note("unique-token-zzz", ts = 1)
store.insert(n)
assertTrue(root.resolve("idx/fts/unique").exists())
@@ -26,6 +26,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
import com.vitorpamplona.quartz.utils.Secp256k1Instance
import kotlinx.coroutines.runBlocking
import java.nio.file.Files
import java.nio.file.Path
import kotlin.io.path.exists
@@ -72,7 +73,8 @@ class FsSlotsTest {
)
@Test
fun `newer replaceable evicts older`() {
fun `newer replaceable evicts older`() =
runBlocking {
val v1 = metadata("old", 100)
val v2 = metadata("new", 200)
store.insert(v1)
@@ -87,7 +89,8 @@ class FsSlotsTest {
}
@Test
fun `older replaceable is rejected when newer exists`() {
fun `older replaceable is rejected when newer exists`() =
runBlocking {
val newer = metadata("new", 200)
val older = metadata("old", 100)
store.insert(newer)
@@ -102,7 +105,8 @@ class FsSlotsTest {
}
@Test
fun `equal timestamp replaceable resolves by lexical id (NIP-01)`() {
fun `equal timestamp replaceable resolves by lexical id (NIP-01)`() =
runBlocking {
val a = metadata("a", 100)
val b = metadata("b", 100)
// NIP-01 tiebreaker: when createdAt ties, the lexically smaller
@@ -119,7 +123,8 @@ class FsSlotsTest {
}
@Test
fun `equal timestamp replaceable rejects higher id when winner already present`() {
fun `equal timestamp replaceable rejects higher id when winner already present`() =
runBlocking {
val a = metadata("a", 100)
val b = metadata("b", 100)
val (winner, loser) = if (a.id < b.id) a to b else b to a
@@ -134,7 +139,8 @@ class FsSlotsTest {
}
@Test
fun `replaceable slot file contains the current winner`() {
fun `replaceable slot file contains the current winner`() =
runBlocking {
val v = metadata("only", 100)
store.insert(v)
@@ -145,7 +151,8 @@ class FsSlotsTest {
}
@Test
fun `replaceable slot survives canonical deletion via hardlink`() {
fun `replaceable slot survives canonical deletion via hardlink`() =
runBlocking {
val v = metadata("x", 100)
store.insert(v)
@@ -165,7 +172,8 @@ class FsSlotsTest {
}
@Test
fun `eviction unlinks index hardlinks for the old winner`() {
fun `eviction unlinks index hardlinks for the old winner`() =
runBlocking {
val v1 = metadata("old", 100)
val v2 = metadata("new", 200)
store.insert(v1)
@@ -182,7 +190,8 @@ class FsSlotsTest {
}
@Test
fun `slot shortcut serves replaceable queries even when idx is wiped`() {
fun `slot shortcut serves replaceable queries even when idx is wiped`() =
runBlocking {
// Belt-and-suspenders for the planner shortcut: a query pinned to
// (kinds=[0], authors=[pk]) must hit the slot directly without
// touching idx/. Wipe idx/ to prove the shortcut isn't relying on
@@ -203,7 +212,8 @@ class FsSlotsTest {
}
@Test
fun `slot shortcut serves addressable queries when d-tag supplied`() {
fun `slot shortcut serves addressable queries when d-tag supplied`() =
runBlocking {
val v = article("intro", "v", 10)
store.insert(v)
java.nio.file.Files
@@ -227,7 +237,8 @@ class FsSlotsTest {
}
@Test
fun `delete of current replaceable winner clears the slot`() {
fun `delete of current replaceable winner clears the slot`() =
runBlocking {
val v = metadata("only", 100)
store.insert(v)
@@ -255,7 +266,8 @@ class FsSlotsTest {
)
@Test
fun `newer addressable evicts older for same d-tag`() {
fun `newer addressable evicts older for same d-tag`() =
runBlocking {
val v1 = article("intro", "draft 1", 10)
val v2 = article("intro", "draft 2", 20)
store.insert(v1)
@@ -267,7 +279,8 @@ class FsSlotsTest {
}
@Test
fun `addressable with different d-tags coexist`() {
fun `addressable with different d-tags coexist`() =
runBlocking {
val intro = article("intro", "hello", 10)
val about = article("about", "bio", 15)
store.insert(intro)
@@ -278,7 +291,8 @@ class FsSlotsTest {
}
@Test
fun `older addressable is rejected when newer exists`() {
fun `older addressable is rejected when newer exists`() =
runBlocking {
val newer = article("slug", "new", 200)
val older = article("slug", "old", 100)
store.insert(newer)
@@ -289,7 +303,8 @@ class FsSlotsTest {
}
@Test
fun `addressable slot file contains the current winner`() {
fun `addressable slot file contains the current winner`() =
runBlocking {
val v = article("intro", "hello", 10)
store.insert(v)
@@ -301,7 +316,8 @@ class FsSlotsTest {
}
@Test
fun `empty d-tag gets its own slot`() {
fun `empty d-tag gets its own slot`() =
runBlocking {
val v = article("", "homepage", 1)
store.insert(v)
@@ -311,7 +327,8 @@ class FsSlotsTest {
}
@Test
fun `delete of current addressable winner clears the slot`() {
fun `delete of current addressable winner clears the slot`() =
runBlocking {
val v = article("intro", "hello", 10)
store.insert(v)
val dHash = FsLayout.sha256Hex("intro")
@@ -327,7 +344,8 @@ class FsSlotsTest {
// ------------------------------------------------------------------
@Test
fun `regular text note has no slot`() {
fun `regular text note has no slot`() =
runBlocking {
val note =
signer.sign<Event>(
createdAt = 1,
@@ -27,6 +27,7 @@ import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import com.vitorpamplona.quartz.nip62RequestToVanish.RequestToVanishEvent
import com.vitorpamplona.quartz.utils.Secp256k1Instance
import kotlinx.coroutines.runBlocking
import java.nio.file.Files
import java.nio.file.Path
import kotlin.io.path.exists
@@ -81,7 +82,8 @@ class FsVanishTest {
// ------------------------------------------------------------------
@Test
fun `vanish for this relay cascades older events from the same author`() {
fun `vanish for this relay cascades older events from the same author`() =
runBlocking {
val n1 = note("a", 10)
val n2 = note("b", 20)
val n3 = note("c", 30) // same ts as vanish — survives because cascade uses strict <
@@ -99,7 +101,8 @@ class FsVanishTest {
}
@Test
fun `vanish for a different relay does NOT cascade`() {
fun `vanish for a different relay does NOT cascade`() =
runBlocking {
val n = note("x", 10)
store.insert(n)
@@ -117,7 +120,8 @@ class FsVanishTest {
}
@Test
fun `vanishFromEverywhere always cascades regardless of relay`() {
fun `vanishFromEverywhere always cascades regardless of relay`() =
runBlocking {
val n = note("x", 10)
store.insert(n)
@@ -132,7 +136,8 @@ class FsVanishTest {
// ------------------------------------------------------------------
@Test
fun `events older than vanish are blocked from re-insertion`() {
fun `events older than vanish are blocked from re-insertion`() =
runBlocking {
val n = note("a", 10)
store.insert(n)
@@ -150,7 +155,8 @@ class FsVanishTest {
}
@Test
fun `events at vanish ts are blocked, parity with SQLite`() {
fun `events at vanish ts are blocked, parity with SQLite`() =
runBlocking {
val v = vanish(ts = 50)
store.insert(v)
@@ -160,7 +166,8 @@ class FsVanishTest {
}
@Test
fun `events newer than vanish still pass`() {
fun `events newer than vanish still pass`() =
runBlocking {
val v = vanish(ts = 50)
store.insert(v)
@@ -170,7 +177,8 @@ class FsVanishTest {
}
@Test
fun `another author is unaffected by my vanish`() {
fun `another author is unaffected by my vanish`() =
runBlocking {
val mine = note("mine", 10)
store.insert(mine)
val theirs = note("theirs", 5, s = otherSigner)
@@ -188,7 +196,8 @@ class FsVanishTest {
// ------------------------------------------------------------------
@Test
fun `later vanish raises the cutoff`() {
fun `later vanish raises the cutoff`() =
runBlocking {
val n100 = note("at-100", 100)
store.insert(n100)
store.insert(vanish(ts = 50))
@@ -206,7 +215,8 @@ class FsVanishTest {
}
@Test
fun `earlier vanish does not lower a stronger cutoff`() {
fun `earlier vanish does not lower a stronger cutoff`() =
runBlocking {
store.insert(vanish(ts = 200))
store.insert(vanish(ts = 50)) // older — should be a no-op for the tombstone
@@ -220,7 +230,8 @@ class FsVanishTest {
// ------------------------------------------------------------------
@Test
fun `vanish tombstone shares an inode with the kind-62 event`() {
fun `vanish tombstone shares an inode with the kind-62 event`() =
runBlocking {
val v = vanish(ts = 30)
store.insert(v)
@@ -239,7 +250,8 @@ class FsVanishTest {
// ------------------------------------------------------------------
@Test
fun `vanish event itself is indexed and queryable`() {
fun `vanish event itself is indexed and queryable`() =
runBlocking {
val v = vanish(ts = 30)
store.insert(v)
@@ -0,0 +1,205 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip01Core.store.sqlite
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import com.vitorpamplona.quartz.utils.Secp256k1Instance
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import java.nio.file.Files
import java.nio.file.Path
import kotlin.io.path.deleteIfExists
import kotlin.io.path.exists
import kotlin.test.AfterTest
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
/**
* Stress test for the SQLite connection pool. Pre-pool, two coroutines
* inserting at the same time would race on the shared `SQLiteConnection`
* (`androidx.sqlite` connections aren't thread-safe) and crash with
* either `SQLITE_ERROR: cannot start a transaction within a transaction`
* or a corrupted prepared statement (`SQLITE_MISUSE`).
*
* With [SQLiteConnectionPool] writes serialise behind a coroutine `Mutex`
* and reads run in parallel against a fixed pool of reader connections,
* matching what Room does. The test launches a fan-out of inserts and
* concurrent reads, then asserts every inserted event is visible and the
* count is exact.
*/
class ParallelInsertTest {
private val signer = NostrSignerSync()
private lateinit var dbFile: Path
private lateinit var store: EventStore
@BeforeTest
fun setup() {
Secp256k1Instance
// Use a real file so the pool can hand out independent reader
// connections — :memory: would make every connection a separate DB.
dbFile = Files.createTempFile("parallel-insert-", ".db")
// Driver expects to open the file itself; ensure the placeholder
// is gone so SQLite can create a fresh DB.
Files.deleteIfExists(dbFile)
store = EventStore(dbName = dbFile.toAbsolutePath().toString(), relay = null)
}
@AfterTest
fun tearDown() {
store.close()
// SQLite leaves -wal / -shm sidecars next to the main file under WAL.
listOf("", "-wal", "-shm", "-journal").forEach { suffix ->
Path.of(dbFile.toString() + suffix).deleteIfExists()
}
}
@Test
fun `parallel inserts on N coroutines all succeed`() =
runBlocking {
val perCoroutine = 200
val coroutines = 8
val total = perCoroutine * coroutines
val events =
(0 until total).map { i ->
signer.sign(TextNoteEvent.build("p$i", createdAt = i.toLong() + 1))
}
// Fan out inserts across `coroutines` workers on the IO
// dispatcher (multi-thread). Without the pool's writer mutex
// these all race on a single SQLiteConnection and crash.
coroutineScope {
events.chunked(perCoroutine).forEach { chunk ->
launch(Dispatchers.IO) {
for (e in chunk) store.insert(e)
}
}
}
assertEquals(total, store.count(Filter()), "every insert must be visible")
val byId = store.query<TextNoteEvent>(Filter()).associateBy { it.id }
for (e in events) {
assertTrue(byId.containsKey(e.id), "missing event ${e.id.take(8)}")
}
}
@Test
fun `parallel reads run alongside writes without crashing`() =
runBlocking {
val writes = 500
val events =
(0 until writes).map { i ->
signer.sign(TextNoteEvent.build("rw$i", createdAt = i.toLong() + 1))
}
coroutineScope {
// Writer feed.
launch(Dispatchers.IO) {
for (e in events) store.insert(e)
}
// Multiple reader fans-out: count() and query() running
// continuously while inserts are still in flight. Asserts
// none of these crash with SQLITE_MISUSE.
val readers =
List(4) {
async(Dispatchers.IO) {
var lastSeen = 0
repeat(100) {
val n = store.count(Filter())
assertTrue(n in 0..writes)
if (n > lastSeen) lastSeen = n
}
lastSeen
}
}
readers.awaitAll()
}
assertEquals(writes, store.count(Filter()))
}
@Test
fun `parallel transaction batches all commit`() =
runBlocking {
val batches = 8
val perBatch = 50
val total = batches * perBatch
val events =
(0 until total).map { i ->
signer.sign(TextNoteEvent.build("t$i", createdAt = i.toLong() + 1))
}
// Each coroutine wraps its slice in store.transaction { ... },
// exercising the writer mutex around BEGIN/COMMIT pairs.
coroutineScope {
events.chunked(perBatch).forEach { chunk ->
launch(Dispatchers.IO) {
store.transaction {
for (e in chunk) insert(e)
}
}
}
}
assertEquals(total, store.count(Filter()))
}
@Test
fun `pool with file-backed db survives reopen`() =
runBlocking {
// Smoke test that the pool migration runs idempotently when
// a writer connection is reopened against an existing DB.
val first = signer.sign(TextNoteEvent.build("first", createdAt = 1))
store.insert(first)
store.close()
val reopened = EventStore(dbName = dbFile.toAbsolutePath().toString(), relay = null)
try {
assertTrue(dbFile.exists())
val got = reopened.query<TextNoteEvent>(Filter(ids = listOf(first.id)))
assertEquals(listOf(first.id), got.map { it.id })
// And then more parallel inserts still work on the
// reopened pool.
val moreCount = 20
val more = (0 until moreCount).map { signer.sign(TextNoteEvent.build("m$it", createdAt = it.toLong() + 100)) }
coroutineScope {
more.forEach { e ->
launch(Dispatchers.IO) { reopened.insert(e) }
}
}
assertEquals(1 + moreCount, reopened.count(Filter()))
} finally {
reopened.close()
}
}
}