Radish alpha
h
rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5
Radicle Heartwood Protocol & Stack
Radicle
Git
Relax sqlite `synchronous` flag
Merged yorgos-laptop opened 2 months ago

According to the docs [1]:

WAL mode is safe from corruption with synchronous=NORMAL, and probably DELETE mode is safe too on modern filesystems. WAL mode is always consistent with synchronous=NORMAL, but WAL mode does lose durability. A transaction committed in WAL mode with synchronous=NORMAL might roll back following a power loss or system crash. Transactions are durable across application crashes regardless of the synchronous setting or journal mode.

Also:

You lose durability across power lose with synchronous NORMAL in WAL mode, but that is not important for most applications. Transactions are still atomic, consistent, and isolated, which are the most important characteristics in most use cases.

The tradeoff here is that SQLite will not aggressively fsync() after every transaction, so we should see less disk pressure.

[1] - https://sqlite.org/wal.html

5 files changed +191 -15 9ff67562 5aaf978f
modified crates/radicle-cli/examples/rad-config.md
@@ -52,6 +52,12 @@ $ rad config
    "workers": 8,
    "seedingPolicy": {
      "default": "block"
+
    },
+
    "database": {
+
      "sqlite": {
+
        "journalMode": "WAL",
+
        "synchronous": "NORMAL"
+
      }
    }
  }
}
modified 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::JournalMode::default())?
+
            .journal_mode(config.database.sqlite.journal_mode)?
+
            .synchronous(config.database.sqlite.synchronous)?
            .init(
                &id,
                announcement.features,
modified crates/radicle/src/node/config.rs
@@ -349,6 +349,37 @@ pub enum Relay {
    Auto,
}

+
/// Database configuration.
+
#[derive(Debug, Default, Copy, Clone, Serialize, Deserialize)]
+
#[serde(rename_all = "camelCase")]
+
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
+
pub struct DatabaseConfig {
+
    /// SQLite configuration.
+
    pub sqlite: SqliteConfig,
+
}
+

+
/// SQLite-specific database configuration.
+
#[derive(Debug, Copy, Clone, Serialize, Deserialize)]
+
#[serde(rename_all = "camelCase")]
+
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
+
pub struct SqliteConfig {
+
    /// Journal mode. WAL is recommended for concurrent access.
+
    #[serde(default)]
+
    pub journal_mode: node::db::JournalMode,
+
    /// Synchronous flag. Controls how often SQLite syncs to disk.
+
    #[serde(default)]
+
    pub synchronous: node::db::Synchronous,
+
}
+

+
impl Default for SqliteConfig {
+
    fn default() -> Self {
+
        Self {
+
            journal_mode: node::db::JournalMode::WAL,
+
            synchronous: node::db::Synchronous::NORMAL,
+
        }
+
    }
+
}
+

/// Proxy configuration.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", tag = "mode")]
@@ -508,6 +539,9 @@ pub struct Config {
    /// Default seeding policy.
    #[serde(default)]
    pub seeding_policy: DefaultSeedingPolicy,
+
    /// Database configuration.
+
    #[serde(default)]
+
    pub database: DatabaseConfig,
    /// Extra fields that aren't supported.
    #[serde(flatten, skip_serializing)]
    pub extra: json::Map<String, json::Value>,
@@ -544,6 +578,7 @@ impl Config {
            workers: Workers::default(),
            log: LogLevel::default(),
            seeding_policy: DefaultSeedingPolicy::default(),
+
            database: DatabaseConfig::default(),
            extra: json::Map::default(),
            secret: None,
        }
@@ -828,4 +863,121 @@ mod test {
            .unwrap()
        );
    }
+

+
    #[test]
+
    fn database_config_valid_combinations() {
+
        use super::{node, Config};
+

+
        let cases = [
+
            // (journal_mode, synchronous, expected_journal, expected_sync, description)
+
            (
+
                None,
+
                None,
+
                node::db::JournalMode::WAL,
+
                node::db::Synchronous::NORMAL,
+
                "defaults",
+
            ),
+
            (
+
                Some("wal"),
+
                Some("NORMAL"),
+
                node::db::JournalMode::WAL,
+
                node::db::Synchronous::NORMAL,
+
                "WAL+NORMAL (recommended)",
+
            ),
+
            (
+
                Some("wal"),
+
                Some("FULL"),
+
                node::db::JournalMode::WAL,
+
                node::db::Synchronous::FULL,
+
                "WAL+FULL (max durability)",
+
            ),
+
            (
+
                Some("wal"),
+
                Some("OFF"),
+
                node::db::JournalMode::WAL,
+
                node::db::Synchronous::OFF,
+
                "WAL+OFF (max performance)",
+
            ),
+
            (
+
                Some("rollback"),
+
                Some("FULL"),
+
                node::db::JournalMode::DELETE,
+
                node::db::Synchronous::FULL,
+
                "DELETE+FULL",
+
            ),
+
            (
+
                Some("rollback"),
+
                Some("EXTRA"),
+
                node::db::JournalMode::DELETE,
+
                node::db::Synchronous::EXTRA,
+
                "DELETE+EXTRA (max durability)",
+
            ),
+
            (
+
                Some("WAL"),
+
                Some("NORMAL"),
+
                node::db::JournalMode::WAL,
+
                node::db::Synchronous::NORMAL,
+
                "WAL uppercase",
+
            ),
+
            (
+
                Some("DELETE"),
+
                Some("NORMAL"),
+
                node::db::JournalMode::DELETE,
+
                node::db::Synchronous::NORMAL,
+
                "DELETE uppercase",
+
            ),
+
        ];
+

+
        for (journal_mode, synchronous, expected_journal, expected_sync, description) in cases {
+
            let mut json_value = json!({"alias": "example"});
+

+
            if let (Some(jm), Some(sync)) = (journal_mode, synchronous) {
+
                json_value["database"] = json!({
+
                    "sqlite": {
+
                        "journalMode": jm,
+
                        "synchronous": sync
+
                    }
+
                });
+
            }
+

+
            let config: Config = serde_json::from_value(json_value)
+
                .unwrap_or_else(|e| panic!("Failed to parse config for {description}: {e}"));
+

+
            assert_eq!(
+
                config.database.sqlite.journal_mode, expected_journal,
+
                "journal_mode mismatch for {description}"
+
            );
+
            assert_eq!(
+
                config.database.sqlite.synchronous, expected_sync,
+
                "synchronous mismatch for {description}"
+
            );
+
        }
+
    }
+

+
    #[test]
+
    fn database_config_rejects_invalid_values() {
+
        use super::Config;
+

+
        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 json_value = json!({"alias": "example", "database": {"sqlite": {}}});
+
            if let Some(jm) = journal_mode {
+
                json_value["database"]["sqlite"]["journalMode"] = json!(jm);
+
            }
+
            if let Some(sync) = synchronous {
+
                json_value["database"]["sqlite"]["synchronous"] = json!(sync);
+
            }
+

+
            assert!(
+
                serde_json::from_value::<Config>(json_value).is_err(),
+
                "Should reject {description}"
+
            );
+
        }
+
    }
}
modified crates/radicle/src/node/db.rs
@@ -50,16 +50,31 @@ pub enum Error {
}

/// Database journal mode.
-
#[derive(Debug, Default, Copy, Clone, serde::Serialize, serde::Deserialize)]
-
#[serde(rename_all = "camelCase")]
+
#[derive(Debug, Default, Copy, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
+
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub enum JournalMode {
    /// "WAL" mode. Good for concurrent reads & writes, but keeps some extra files around.
-
    #[serde(rename = "wal")]
+
    #[serde(alias = "wal")]
    #[default]
-
    WriteAheadLog,
+
    WAL,
    /// Default "rollback" mode. Certain writes may block reads.
    #[serde(alias = "rollback")]
-
    Rollback,
+
    DELETE,
+
}
+

+
/// SQLite synchronous flag value.
+
#[derive(Debug, Default, Copy, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
+
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
+
pub enum Synchronous {
+
    /// OFF - No syncing at all. Fastest but least safe.
+
    OFF,
+
    /// NORMAL - Sync at critical moments. Good balance of safety and performance.
+
    #[default]
+
    NORMAL,
+
    /// FULL - Sync at every critical moment. Safest but slowest.
+
    FULL,
+
    /// EXTRA - Like FULL with additional syncing.
+
    EXTRA,
}

/// A file-backed database storing information about the network.
@@ -117,14 +132,15 @@ impl Database {

    /// Set journal mode.
    pub fn journal_mode(self, mode: JournalMode) -> Result<Self, Error> {
-
        match mode {
-
            JournalMode::Rollback => {
-
                self.db.execute("PRAGMA journal_mode = DELETE;")?;
-
            }
-
            JournalMode::WriteAheadLog => {
-
                self.db.execute("PRAGMA journal_mode = WAL;")?;
-
            }
-
        }
+
        self.db
+
            .execute(format!("PRAGMA journal_mode = {mode:?};"))?;
+
        Ok(self)
+
    }
+

+
    /// Set synchronous flag.
+
    pub fn synchronous(self, synchronous: Synchronous) -> Result<Self, Error> {
+
        self.db
+
            .execute(format!("PRAGMA synchronous = {synchronous:?};"))?;
        Ok(self)
    }

modified crates/radicle/src/profile.rs
@@ -253,7 +253,8 @@ impl Profile {
        home.policies_mut()?;
        home.notifications_mut()?;
        home.database_mut()?
-
            .journal_mode(node::db::JournalMode::default())?
+
            .journal_mode(config.node.database.sqlite.journal_mode)?
+
            .synchronous(config.node.database.sqlite.synchronous)?
            .init(
                &public_key,
                config.node.features(),