From f4aee203abb15130745c5195bbc32fd373286991 Mon Sep 17 00:00:00 2001 From: Yorgos Saslis Date: Fri, 20 Feb 2026 18:08:59 +0200 Subject: [PATCH] radicle: Make SQLite pragmas configurable Expose choosing particular SQLite pragmas via configuration. --- crates/radicle-cli/examples/rad-config.md | 60 +++++++ crates/radicle-node/src/runtime.rs | 3 +- crates/radicle/CHANGELOG.md | 4 + crates/radicle/src/node/config.rs | 4 + crates/radicle/src/node/db.rs | 1 + crates/radicle/src/node/db/config.rs | 183 ++++++++++++++++++++++ crates/radicle/src/profile.rs | 3 +- 7 files changed, 256 insertions(+), 2 deletions(-) create mode 100644 crates/radicle/src/node/db/config.rs diff --git a/crates/radicle-cli/examples/rad-config.md b/crates/radicle-cli/examples/rad-config.md index 2e5ed570..3a991afe 100644 --- a/crates/radicle-cli/examples/rad-config.md +++ b/crates/radicle-cli/examples/rad-config.md @@ -311,6 +311,10 @@ $ rad config schema "default": "block" } }, + "database": { + "description": "Database configuration.", + "$ref": "#/$defs/Config" + }, "secret": { "description": "Path to a file containing an Ed25519 secret key, in OpenSSH format, i.e./nwith the `-----BEGIN OPENSSH PRIVATE KEY-----` header. The corresponding/npublic key will be used as the Node ID./n/nA decryption password cannot be configured, but passed at runtime via/nthe environment variable `RAD_PASSPHRASE`.", "type": [ @@ -660,6 +664,62 @@ $ rad config schema "const": "all" } ] + }, + "Config": { + "description": "Database configuration.", + "type": "object", + "properties": { + "sqlite": { + "description": "SQLite configuration.", + "$ref": "#/$defs/SqliteConfig" + } + }, + "required": [ + "sqlite" + ] + }, + "SqliteConfig": { + "description": "SQLite database configuration.", + "type": "object", + "properties": { + "pragma": { + "$ref": "#/$defs/Pragma" + } + } + }, + "Pragma": { + "description": "Global SQLite pragma statements to make in order to configure SQLite itself,/nsee .", + "type": "object", + "properties": { + "journalMode": { + "$ref": "#/$defs/JournalMode" + }, + "synchronous": { + "$ref": "#/$defs/Synchronous" + } + } + }, + "JournalMode": { + "description": "Value for a `journal_mode` pragma statement./nFor a description of all variants please refer to/n./nNote that when SQLite documentation talks about /"the application/",/nthe application linked against this crate, e.g. Radicle Node, Radicle CLI,/nand others, is meant.", + "type": "string", + "enum": [ + "DELETE", + "TRUNCATE", + "PERSIST", + "MEMORY", + "WAL", + "OFF" + ] + }, + "Synchronous": { + "description": "Value for a `synchronous` pragma statement./nFor a description of all variants please refer to/n.", + "type": "string", + "enum": [ + "EXTRA", + "FULL", + "NORMAL", + "OFF" + ] } } } diff --git a/crates/radicle-node/src/runtime.rs b/crates/radicle-node/src/runtime.rs index 0f5d61f1..a4fc789a 100644 --- a/crates/radicle-node/src/runtime.rs +++ b/crates/radicle-node/src/runtime.rs @@ -177,7 +177,8 @@ impl Runtime { log::info!(target: "node", "Opening node database.."); let db = home .database_mut()? - .journal_mode(node::db::sqlite_ext::JournalMode::WAL)? + .journal_mode(config.database.sqlite.pragma.journal_mode)? + .synchronous(config.database.sqlite.pragma.synchronous)? .init( &id, announcement.features, diff --git a/crates/radicle/CHANGELOG.md b/crates/radicle/CHANGELOG.md index 4e251568..d5dad941 100644 --- a/crates/radicle/CHANGELOG.md +++ b/crates/radicle/CHANGELOG.md @@ -9,6 +9,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- SQLite configuration is modeled as `radicle::node::db::config::Config` + and can be configured via `radicle::profile::config::Config`. + The two pragmas `journal_mode` and `synchronous` are exposed this way. + ### Changed ### Removed diff --git a/crates/radicle/src/node/config.rs b/crates/radicle/src/node/config.rs index f32449fe..373e2c4a 100644 --- a/crates/radicle/src/node/config.rs +++ b/crates/radicle/src/node/config.rs @@ -509,6 +509,9 @@ pub struct Config { /// Default seeding policy. #[serde(default)] pub seeding_policy: DefaultSeedingPolicy, + /// Database configuration. + #[serde(default, skip_serializing_if = "crate::serde_ext::is_default")] + pub database: node::db::config::Config, /// Extra fields that aren't supported. #[serde(flatten, skip_serializing)] pub extra: json::Map, @@ -545,6 +548,7 @@ impl Config { workers: Workers::default(), log: LogLevel::default(), seeding_policy: DefaultSeedingPolicy::default(), + database: node::db::config::Config::default(), extra: json::Map::default(), secret: None, } diff --git a/crates/radicle/src/node/db.rs b/crates/radicle/src/node/db.rs index 8b2a2c02..845345cf 100644 --- a/crates/radicle/src/node/db.rs +++ b/crates/radicle/src/node/db.rs @@ -20,6 +20,7 @@ use crate::node::{ }; use crate::sql::transaction; +pub mod config; pub mod sqlite_ext; /// How long to wait for the database lock to be released before failing a read. diff --git a/crates/radicle/src/node/db/config.rs b/crates/radicle/src/node/db/config.rs new file mode 100644 index 00000000..c57e80c6 --- /dev/null +++ b/crates/radicle/src/node/db/config.rs @@ -0,0 +1,183 @@ +use super::sqlite_ext::*; + +const DEFAULT_JOURNAL_MODE: JournalMode = JournalMode::WAL; +const DEFAULT_SYNCHRONOUS: Synchronous = Synchronous::FULL; + +/// Database configuration. +#[derive(Debug, Default, Copy, Clone, PartialEq, ::serde::Serialize, ::serde::Deserialize)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +pub struct Config { + /// SQLite configuration. + #[serde(skip_serializing_if = "crate::serde_ext::is_default")] + pub sqlite: SqliteConfig, +} + +/// SQLite database configuration. +#[derive(Debug, Default, Copy, Clone, PartialEq, ::serde::Serialize, ::serde::Deserialize)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +pub struct SqliteConfig { + #[serde(default, skip_serializing_if = "crate::serde_ext::is_default")] + pub pragma: Pragma, +} + +/// Global SQLite pragma statements to make in order to configure SQLite itself, +/// see . +#[derive(Debug, Copy, Clone, PartialEq, ::serde::Serialize, ::serde::Deserialize)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +pub struct Pragma { + #[serde( + default = "serde::journal_mode::default", + skip_serializing_if = "serde::journal_mode::is_default" + )] + pub journal_mode: JournalMode, + + #[serde( + default = "serde::synchronous::default", + skip_serializing_if = "serde::synchronous::is_default" + )] + pub synchronous: Synchronous, +} + +pub mod serde { + use super::*; + + pub mod journal_mode { + use super::*; + + pub fn default() -> JournalMode { + DEFAULT_JOURNAL_MODE + } + + pub fn is_default(journal_mode: &JournalMode) -> bool { + matches!(journal_mode, &DEFAULT_JOURNAL_MODE) + } + } + + pub mod synchronous { + use super::*; + + pub fn default() -> Synchronous { + DEFAULT_SYNCHRONOUS + } + + pub fn is_default(synchronous: &Synchronous) -> bool { + matches!(synchronous, &DEFAULT_SYNCHRONOUS) + } + } +} + +impl Default for Pragma { + fn default() -> Self { + Self { + journal_mode: DEFAULT_JOURNAL_MODE, + synchronous: DEFAULT_SYNCHRONOUS, + } + } +} + +#[cfg(test)] +mod test { + use crate::assert_matches; + + use super::*; + use serde_json::json; + + #[test] + fn database_config_valid_combinations() { + let cases = [ + (None, None, JournalMode::WAL, Synchronous::FULL), + ( + Some("WAL"), + Some("NORMAL"), + JournalMode::WAL, + Synchronous::NORMAL, + ), + ( + Some("WAL"), + Some("FULL"), + JournalMode::WAL, + Synchronous::FULL, + ), + (Some("WAL"), Some("OFF"), JournalMode::WAL, Synchronous::OFF), + ( + Some("DELETE"), + Some("FULL"), + JournalMode::DELETE, + Synchronous::FULL, + ), + ( + Some("DELETE"), + Some("EXTRA"), + JournalMode::DELETE, + Synchronous::EXTRA, + ), + ( + Some("WAL"), + Some("NORMAL"), + JournalMode::WAL, + Synchronous::NORMAL, + ), + ( + Some("DELETE"), + Some("NORMAL"), + JournalMode::DELETE, + Synchronous::NORMAL, + ), + ]; + + for (journal_mode, synchronous, expected_journal_mode, expected_synchronous) in cases { + let mut config = json!({}); + + if let Some(journal_mode) = journal_mode { + config["pragma"]["journalMode"] = json!(journal_mode); + } + + if let Some(synchronous) = synchronous { + config["pragma"]["synchronous"] = json!(synchronous); + } + + #[allow(clippy::unwrap_used)] + let config: SqliteConfig = serde_json::from_value(config).unwrap(); + + assert_eq!( + config.pragma, + Pragma { + journal_mode: expected_journal_mode, + synchronous: expected_synchronous + } + ); + } + } + + #[test] + fn invalid() { + let invalid_cases = [ + (Some("INVALID"), Some("NORMAL"), "invalid journal_mode"), + (Some("WAL"), Some("INVALID"), "invalid synchronous"), + (Some("WAL"), Some("normal"), "lowercase synchronous"), + (Some("Wal"), Some("NORMAL"), "mixed case journal_mode"), + ]; + + for (journal_mode, synchronous, description) in invalid_cases { + let mut pragma = json!({}); + + if let Some(journal_mode) = journal_mode { + pragma["journalMode"] = json!(journal_mode); + } + + if let Some(synchronous) = synchronous { + pragma["synchronous"] = json!(synchronous); + } + + assert_matches!( + serde_json::from_value::(pragma), + Err(_), + "{}", + description, + ); + } + } +} diff --git a/crates/radicle/src/profile.rs b/crates/radicle/src/profile.rs index 0d3420f6..b325bb8a 100644 --- a/crates/radicle/src/profile.rs +++ b/crates/radicle/src/profile.rs @@ -253,7 +253,8 @@ impl Profile { home.policies_mut()?; home.notifications_mut()?; home.database_mut()? - .journal_mode(node::db::sqlite_ext::JournalMode::WAL)? + .journal_mode(config.node.database.sqlite.pragma.journal_mode)? + .synchronous(config.node.database.sqlite.pragma.synchronous)? .init( &public_key, config.node.features(),