diff --git a/geode/config.example.toml b/geode/config.example.toml index 6b18b478d2..6c50f51848 100644 --- a/geode/config.example.toml +++ b/geode/config.example.toml @@ -46,6 +46,22 @@ path = "/" in_memory = false file = "/var/lib/geode/events.db" +# Reader-connection pool size (file-backed stores only). Default 4. +# readers = 4 + +# PRAGMA mmap_size in bytes — maps the db file into memory so reads +# skip the pread syscall. Off by default (SQLite default). +# mmap_size = 268435456 + +# PRAGMA temp_store = MEMORY — RAM instead of temp files for large +# sorts. Off by default. +# temp_store_memory = true + +# Refresh query-planner statistics (PRAGMA optimize) every N seconds. +# Incremental and usually a no-op; keeps the planner from drifting +# onto the wrong index as the corpus grows. Off by default. +# optimize_interval_seconds = 3600 + [options] # Drop events whose Schnorr signature does not verify. Strongly # recommended for any relay accepting traffic from real clients. diff --git a/geode/src/main/kotlin/com/vitorpamplona/geode/Main.kt b/geode/src/main/kotlin/com/vitorpamplona/geode/Main.kt index 19e470853b..4a7b1e6179 100644 --- a/geode/src/main/kotlin/com/vitorpamplona/geode/Main.kt +++ b/geode/src/main/kotlin/com/vitorpamplona/geode/Main.kt @@ -37,9 +37,14 @@ import com.vitorpamplona.quartz.nip01Core.relay.server.policies.OptionalAuthPoli import com.vitorpamplona.quartz.nip01Core.relay.server.policies.RejectFutureEventsPolicy import com.vitorpamplona.quartz.nip01Core.relay.server.policies.VerifyAuthOnlyPolicy import com.vitorpamplona.quartz.nip01Core.relay.server.policies.VerifyPolicy -import com.vitorpamplona.quartz.nip01Core.store.IEventStore import com.vitorpamplona.quartz.nip01Core.store.sqlite.EventStore import com.vitorpamplona.quartz.nip77Negentropy.NegentropySettings +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch import java.io.File /** @@ -126,11 +131,20 @@ fun main(args: Array) { cliInfoFile?.let { RelayInfo.fromFile(it) } ?: config.resolveInfo(fullTextSearch) - val store: IEventStore = + // Deployment tuning from `[database]` — off by default; the quartz + // library defaults stay tuned for the app-side stores. + val extraPragmas = + buildList { + config.database.mmap_size?.let { add("PRAGMA mmap_size = $it;") } + if (config.database.temp_store_memory) add("PRAGMA temp_store = MEMORY;") + } + val store = EventStore( dbName = dbFile, relay = advertisedUrl, indexStrategy = relayIndexingStrategy(fullTextSearch, config.negentropy.live_index), + numReaders = config.database.readers ?: 4, + extraPragmas = extraPragmas, ) val policyBuilder: () -> IRelayPolicy = { @@ -223,12 +237,26 @@ fun main(args: Array) { MirrorWorker(upstreams, relay.server).also { it.start() } } + // Periodic query-planner statistics refresh (`PRAGMA optimize`). + // Incremental and usually a no-op; failures are swallowed — a missed + // refresh only means slightly staler planner stats until the next tick. + val maintenanceScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + config.database.optimize_interval_seconds?.let { secs -> + maintenanceScope.launch { + while (true) { + delay(secs * 1000) + runCatching { store.optimize() } + } + } + } + Runtime.getRuntime().addShutdownHook( Thread { // Each step wrapped so a throw in any stage doesn't skip // `relay.close()` (which closes the SQLite store). The mirror - // goes first: stop pulling new events before the queue and - // store beneath it shut down. + // and maintenance go first: stop touching the store before the + // queue and store beneath them shut down. + runCatching { maintenanceScope.cancel() } runCatching { mirror?.close() } runCatching { server.stop() } runCatching { relay.close() } diff --git a/geode/src/main/kotlin/com/vitorpamplona/geode/config/StaticConfig.kt b/geode/src/main/kotlin/com/vitorpamplona/geode/config/StaticConfig.kt index 7079a3fa28..7145139f57 100644 --- a/geode/src/main/kotlin/com/vitorpamplona/geode/config/StaticConfig.kt +++ b/geode/src/main/kotlin/com/vitorpamplona/geode/config/StaticConfig.kt @@ -106,6 +106,30 @@ data class StaticConfig( /** True keeps an in-memory SQLite db (default — events vanish on restart). */ val in_memory: Boolean = true, val file: String? = null, + /** + * Reader-connection pool size. `null` keeps quartz's default (4). + * Only meaningful for file-backed stores; in-memory databases + * share the single writer connection regardless. + */ + val readers: Int? = null, + /** + * `PRAGMA mmap_size` in bytes, e.g. `268435456` for 256 MiB. + * Maps the database file into memory so reads skip the pread + * syscall + page-cache copy. `null` keeps SQLite's default (off). + */ + val mmap_size: Long? = null, + /** + * `PRAGMA temp_store = MEMORY` — keeps sort/temp b-trees for + * large queries in RAM instead of temp files. + */ + val temp_store_memory: Boolean = false, + /** + * Refresh query-planner statistics (`PRAGMA analysis_limit; + * PRAGMA optimize`) every this many seconds. Incremental and + * usually a no-op, but keeps the planner from drifting onto the + * wrong index as the corpus grows/changes shape. `null` = never. + */ + val optimize_interval_seconds: Long? = null, ) data class OptionsSection( diff --git a/geode/src/test/kotlin/com/vitorpamplona/geode/config/StaticConfigTest.kt b/geode/src/test/kotlin/com/vitorpamplona/geode/config/StaticConfigTest.kt index 765bc58881..5974fb9294 100644 --- a/geode/src/test/kotlin/com/vitorpamplona/geode/config/StaticConfigTest.kt +++ b/geode/src/test/kotlin/com/vitorpamplona/geode/config/StaticConfigTest.kt @@ -48,6 +48,34 @@ class StaticConfigTest { assertEquals(false, c.options.verify_signatures) } + @Test + fun parsesDatabaseTuningKnobs() { + val toml = + """ + [database] + in_memory = false + file = "/tmp/x.db" + readers = 8 + mmap_size = 268435456 + temp_store_memory = true + optimize_interval_seconds = 3600 + """.trimIndent() + + val c = StaticConfig.fromToml(toml) + + assertEquals(8, c.database.readers) + assertEquals(268435456L, c.database.mmap_size) + assertEquals(true, c.database.temp_store_memory) + assertEquals(3600L, c.database.optimize_interval_seconds) + + // And all knobs default to off/null so plain configs are untouched. + val d = StaticConfig.fromToml("") + assertEquals(null, d.database.readers) + assertEquals(null, d.database.mmap_size) + assertEquals(false, d.database.temp_store_memory) + assertEquals(null, d.database.optimize_interval_seconds) + } + @Test fun mirrorSectionDefaultsToEmpty() { assertTrue(StaticConfig.fromToml("").mirror.isEmpty()) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/EventStore.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/EventStore.kt index 82d1df1c0b..c0eb526e11 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/EventStore.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/EventStore.kt @@ -39,8 +39,14 @@ class EventStore( dbName: String? = "events.db", override val relay: NormalizedRelayUrl? = "wss://quartz.local/".normalizeRelayUrl(), val indexStrategy: IndexingStrategy = DefaultIndexingStrategy(), + numReaders: Int = 4, + /** See [SQLiteEventStore.extraPragmas] — deployment-specific tuning. */ + extraPragmas: List = emptyList(), ) : IEventStore { - val store = SQLiteEventStore(BundledSQLiteDriver(), dbName, relay, indexStrategy) + val store = SQLiteEventStore(BundledSQLiteDriver(), dbName, relay, indexStrategy, numReaders, extraPragmas) + + /** Incremental planner-stats refresh — see [SQLiteEventStore.optimize]. */ + suspend fun optimize() = store.optimize() override suspend fun insert(event: Event) = store.insertEvent(event) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SQLiteEventStore.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SQLiteEventStore.kt index 2a802b4c23..94179e9406 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SQLiteEventStore.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SQLiteEventStore.kt @@ -48,6 +48,15 @@ class SQLiteEventStore( val relay: NormalizedRelayUrl? = null, val indexStrategy: IndexingStrategy = DefaultIndexingStrategy(), val numReaders: Int = 4, + /** + * Extra `PRAGMA` statements run on every pooled connection after the + * built-in configuration (cache size, WAL, busy timeout, …), so they + * can override it. Deployment-specific tuning goes here — e.g. + * `PRAGMA mmap_size = 268435456;` or `PRAGMA temp_store = MEMORY;` + * on server hardware — while the library defaults stay tuned for the + * app-side stores. Statements must be self-contained SQL. + */ + val extraPragmas: List = emptyList(), ) { companion object { const val DATABASE_VERSION = 4 @@ -133,6 +142,9 @@ class SQLiteEventStore( // upgrading its snapshot. With it, SQLite retries internally // for up to N ms before giving up. Matches Room's default. db.execSQL("PRAGMA busy_timeout = 5000;") + + // Deployment overrides last, so they win over the defaults. + extraPragmas.forEach { db.execSQL(it) } }, onMigrate = { db -> val currentVersion = getUserVersion(db) @@ -244,6 +256,20 @@ class SQLiteEventStore( db.execSQL("ANALYZE") } + /** + * Incremental planner-statistics refresh for long-running relays. + * `PRAGMA optimize` re-analyzes only tables whose content changed + * enough to matter, and `analysis_limit` bounds each table's scan — + * so a periodic call stays cheap (typically no-op) while keeping the + * planner from drifting onto the wrong index as the corpus grows. + * Runs on the writer connection; call it off the hot path. + */ + suspend fun optimize() = + pool.useWriter { db -> + db.execSQL("PRAGMA analysis_limit = 400;") + db.execSQL("PRAGMA optimize;") + } + /** * Live-index mutations gathered during one write transaction and * applied only after its COMMIT — a rolled-back row (savepoint or diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/ExtraPragmasTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/ExtraPragmasTest.kt new file mode 100644 index 0000000000..bd1a8d289c --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/ExtraPragmasTest.kt @@ -0,0 +1,69 @@ +/* + * 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 kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * [SQLiteEventStore.extraPragmas] must actually reach every pooled + * connection (after the built-in defaults, so they can override), and + * [SQLiteEventStore.optimize] must run cleanly on a live store. + */ +class ExtraPragmasTest { + private suspend fun SQLiteEventStore.pragmaValue(name: String): Long = + pool.useReader { db -> + db.prepare("PRAGMA $name").use { stmt -> + stmt.step() + stmt.getLong(0) + } + } + + @Test + fun extraPragmasApplyToConnections() = + runTest { + // temp_store: 0=default, 2=MEMORY. + val store = + SQLiteEventStore( + dbName = null, + extraPragmas = listOf("PRAGMA temp_store = MEMORY;"), + ) + assertEquals(2L, store.pragmaValue("temp_store")) + store.close() + } + + @Test + fun defaultsHaveNoExtraPragmas() = + runTest { + val store = SQLiteEventStore(dbName = null) + assertEquals(0L, store.pragmaValue("temp_store")) + store.close() + } + + @Test + fun optimizeRunsCleanly() = + runTest { + val store = SQLiteEventStore(dbName = null) + store.optimize() + store.close() + } +}