mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 19:28:25 +00:00
feat(geode/quartz): [database] tuning knobs + periodic PRAGMA optimize
Backlog items 4-5 plumbing, config-gated and off by default so quartz library defaults stay untouched for the app-side stores: - quartz: SQLiteEventStore/EventStore accept extraPragmas (applied on every pooled connection AFTER the built-in configuration, so they can override it) and expose optimize() — an analysis_limit-bounded PRAGMA optimize for incremental planner-stats refresh. - geode: [database] readers / mmap_size / temp_store_memory map onto the store; optimize_interval_seconds drives a maintenance coroutine (cancelled first in the shutdown hook, before the store closes). The A/B verdict on whether the example config should RECOMMEND any of these on container-class hardware follows in the next commit — the knobs themselves are operator tools worth having either way, since mmap/temp-store value is hardware-dependent. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TtDNpayEYvJH7QuPswND3A
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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<String>) {
|
||||
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<String>) {
|
||||
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() }
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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())
|
||||
|
||||
+7
-1
@@ -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<String> = 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)
|
||||
|
||||
|
||||
+26
@@ -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<String> = 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
|
||||
|
||||
+69
@@ -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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user