iota/iota-storage/src/util/db.rs

366 lines
12 KiB
Rust

use once_cell::sync::Lazy;
use r2d2::ManageConnection;
use rusqlite::Connection;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use crate::storage_error::StorageError;
const DB_NAME: &str = "messages";
/// A simple r2d2 manager for rusqlite connections.
pub struct SqliteManager;
impl ManageConnection for SqliteManager {
type Connection = Connection;
type Error = rusqlite::Error;
fn connect(&self) -> Result<Connection, rusqlite::Error> {
let path = db_file_path(DB_NAME);
let conn = Connection::open(path)?;
conn.execute_batch("PRAGMA journal_mode = WAL; PRAGMA synchronous = NORMAL;")?;
conn.busy_timeout(Duration::from_millis(250))?;
Ok(conn)
}
fn is_valid(&self, conn: &mut Connection) -> Result<(), rusqlite::Error> {
conn.execute_batch("SELECT 1")
}
fn has_broken(&self, _conn: &mut Connection) -> bool {
false
}
}
static POOL: Lazy<Arc<r2d2::Pool<SqliteManager>>> = Lazy::new(|| {
let manager = SqliteManager;
let pool = r2d2::Pool::builder()
.max_size(8)
.build(manager)
.expect("Failed to create database connection pool");
run_migrations(&pool).expect("Failed to run database migrations");
Arc::new(pool)
});
pub fn pool() -> Arc<r2d2::Pool<SqliteManager>> {
POOL.clone()
}
pub fn with_db<T, F>(f: F) -> Result<T, StorageError>
where
F: FnOnce(&Connection) -> Result<T, StorageError>,
{
let conn = POOL.get().map_err(|e| StorageError::Pool(e.to_string()))?;
f(&conn)
}
fn db_file_path(db_name: &str) -> PathBuf {
let storage_dir = iota_util::file_util::storage_directory();
// Creating storage belongs to initialization/connection setup, never to a
// configuration read.
std::fs::create_dir_all(&storage_dir).expect("create Iota storage directory");
storage_dir.join(format!("{db_name}.sqlite3"))
}
fn run_migrations(pool: &r2d2::Pool<SqliteManager>) -> Result<(), StorageError> {
let conn = pool.get().map_err(|e| StorageError::Pool(e.to_string()))?;
run_migrations_on_connection(&conn)
}
/*
* Older builds could apply a schema change without advancing user_version.
* Check each added column so those databases can resume upgrading.
*/
fn add_column_if_missing(
conn: &Connection,
column: &str,
definition: &str,
) -> Result<(), StorageError> {
let mut statement =
conn.prepare("SELECT 1 FROM pragma_table_info('messages') WHERE name = ?1")?;
let exists = statement.exists([column])?;
if !exists {
conn.execute_batch(&format!("ALTER TABLE messages ADD COLUMN {definition};"))?;
}
Ok(())
}
fn run_migrations_on_connection(conn: &Connection) -> Result<(), StorageError> {
let current_version: i64 = conn
.pragma_query_value(None, "user_version", |r| r.get(0))
.unwrap_or(0);
if current_version < 1 {
conn.execute_batch(
r#"
CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
storage_owner INTEGER NOT NULL,
external_user INTEGER NOT NULL,
message_time INTEGER NOT NULL,
content TEXT NOT NULL,
sent_by_self INTEGER NOT NULL,
message_state TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_messages_lookup
ON messages (storage_owner, external_user, message_time DESC);
CREATE TABLE IF NOT EXISTS contacts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
storage_owner INTEGER NOT NULL,
user_id INTEGER NOT NULL,
user_name TEXT,
last_message_at INTEGER,
UNIQUE(storage_owner, user_id)
);
CREATE INDEX IF NOT EXISTS idx_contacts_owner
ON contacts (storage_owner, last_message_at DESC, user_id ASC);
CREATE TABLE IF NOT EXISTS communities (
id INTEGER PRIMARY KEY AUTOINCREMENT,
storage_owner INTEGER NOT NULL,
address TEXT NOT NULL,
title TEXT NOT NULL,
position TEXT NOT NULL,
UNIQUE(storage_owner, address)
);
CREATE INDEX IF NOT EXISTS idx_communities_owner
ON communities (storage_owner);
CREATE TABLE IF NOT EXISTS users (
user_id INTEGER PRIMARY KEY,
username TEXT NOT NULL UNIQUE,
public_key TEXT NOT NULL,
private_key_hash TEXT NOT NULL,
reset_token TEXT NOT NULL,
created_at INTEGER NOT NULL,
display_name TEXT
);
CREATE TABLE IF NOT EXISTS trusted_apps (
user_id INTEGER NOT NULL,
app_id TEXT NOT NULL,
app_secret TEXT NOT NULL,
PRIMARY KEY (user_id, app_id)
);
PRAGMA user_version = 1;
"#,
)?;
}
if current_version < 2 {
add_column_if_missing(conn, "height", "height INTEGER NOT NULL DEFAULT 0")?;
conn.execute_batch("PRAGMA user_version = 2;")?;
}
if current_version < 3 {
add_column_if_missing(conn, "reply_to", "reply_to INTEGER")?;
conn.execute_batch("PRAGMA user_version = 3;")?;
}
if current_version < 4 {
add_column_if_missing(
conn,
"edited_count",
"edited_count INTEGER NOT NULL DEFAULT 0",
)?;
add_column_if_missing(
conn,
"deleted_by_external",
"deleted_by_external INTEGER NOT NULL DEFAULT 0",
)?;
conn.execute_batch(
r#"
CREATE TABLE IF NOT EXISTS message_edits (
id INTEGER PRIMARY KEY AUTOINCREMENT,
message_id INTEGER NOT NULL REFERENCES messages(id),
content_before TEXT NOT NULL,
content_after TEXT NOT NULL,
edited_at INTEGER NOT NULL,
edited_by INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_message_edits_msg
ON message_edits (message_id, edited_at DESC);
CREATE TABLE IF NOT EXISTS reactions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
message_id INTEGER NOT NULL REFERENCES messages(id),
user_id INTEGER NOT NULL,
reaction TEXT NOT NULL,
created_at INTEGER NOT NULL,
UNIQUE(message_id, user_id, reaction)
);
CREATE INDEX IF NOT EXISTS idx_reactions_msg
ON reactions (message_id, reaction);
PRAGMA user_version = 4;
"#,
)?;
}
if current_version < 5 {
conn.execute_batch(
r#"
CREATE TABLE IF NOT EXISTS settings (
user_id INTEGER NOT NULL,
session_id INTEGER NOT NULL,
name TEXT NOT NULL,
payload TEXT NOT NULL,
PRIMARY KEY (user_id, session_id, name)
);
CREATE INDEX IF NOT EXISTS idx_settings_lookup
ON settings (user_id, session_id, name);
PRAGMA user_version = 5;
"#,
)?;
}
if current_version < 6 {
conn.execute_batch(
r#"
CREATE TABLE IF NOT EXISTS sync_heads (
user_id INTEGER PRIMARY KEY,
version INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS sync_events (
user_id INTEGER NOT NULL,
version INTEGER NOT NULL,
entity_type TEXT NOT NULL,
entity_id INTEGER NOT NULL,
operation TEXT NOT NULL,
created_at INTEGER NOT NULL,
PRIMARY KEY (user_id, version)
);
CREATE INDEX IF NOT EXISTS idx_sync_events_user_version
ON sync_events (user_id, version);
CREATE TABLE IF NOT EXISTS client_sync_state (
user_id INTEGER NOT NULL,
session_id INTEGER NOT NULL,
acknowledged_version INTEGER NOT NULL,
cache_schema_version INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
PRIMARY KEY (user_id, session_id)
);
PRAGMA user_version = 6;
"#,
)?;
}
if current_version < 7 {
conn.execute_batch(
r#"
CREATE TABLE IF NOT EXISTS user_residency (
user_id INTEGER PRIMARY KEY,
username TEXT NOT NULL,
lifecycle_state TEXT NOT NULL CHECK (lifecycle_state IN ('managed', 'released')),
data_state TEXT NOT NULL CHECK (data_state IN ('present', 'empty')),
updated_at INTEGER NOT NULL
);
PRAGMA user_version = 7;
"#,
)?;
}
Ok(())
}
pub fn open_connection(db_name: &str) -> Result<Connection, rusqlite::Error> {
let path = db_file_path(db_name);
Connection::open(path)
}
pub fn create_shared_connection(
db_name: &str,
init_sql: &str,
) -> Result<Arc<std::sync::Mutex<Connection>>, String> {
let path = db_file_path(db_name);
let conn = Connection::open(path).map_err(|e| e.to_string())?;
conn.execute_batch(init_sql).map_err(|e| e.to_string())?;
let _ = conn.busy_timeout(Duration::from_millis(250));
Ok(Arc::new(std::sync::Mutex::new(conn)))
}
pub fn with_conn<T, F>(shared: &Arc<std::sync::Mutex<Connection>>, f: F) -> Result<T, String>
where
F: FnOnce(&Connection) -> Result<T, rusqlite::Error>,
{
if tokio::runtime::Handle::try_current().is_ok() {
tokio::task::block_in_place(|| {
let guard = shared
.lock()
.map_err(|e| format!("DB mutex poisoned: {:?}", e))?;
f(&*guard).map_err(|e| e.to_string())
})
} else {
let guard = shared
.lock()
.map_err(|e| format!("DB mutex poisoned: {:?}", e))?;
f(&*guard).map_err(|e| e.to_string())
}
}
/// Legacy - kept for e2ee_storage which uses its own DB.
pub fn create_general_messages_db() -> Result<Arc<std::sync::Mutex<Connection>>, String> {
create_shared_connection(DB_NAME, "")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn resumes_migration_when_height_exists_before_its_version() -> Result<(), StorageError> {
let conn = Connection::open_in_memory()?;
conn.execute_batch(
r#"
CREATE TABLE messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
storage_owner INTEGER NOT NULL,
external_user INTEGER NOT NULL,
message_time INTEGER NOT NULL,
content TEXT NOT NULL,
sent_by_self INTEGER NOT NULL,
message_state TEXT NOT NULL
);
ALTER TABLE messages ADD COLUMN height INTEGER NOT NULL DEFAULT 0;
PRAGMA user_version = 1;
"#,
)?;
run_migrations_on_connection(&conn)?;
let version: i64 = conn.pragma_query_value(None, "user_version", |row| row.get(0))?;
assert_eq!(version, 7);
for column in ["height", "reply_to", "edited_count", "deleted_by_external"] {
let mut statement =
conn.prepare("SELECT 1 FROM pragma_table_info('messages') WHERE name = ?1")?;
assert!(statement.exists([column])?);
}
Ok(())
}
#[test]
fn migrates_version_five_once_and_is_idempotent() -> Result<(), StorageError> {
let conn = Connection::open_in_memory()?;
conn.execute_batch("PRAGMA user_version = 5;")?;
run_migrations_on_connection(&conn)?;
run_migrations_on_connection(&conn)?;
let version: i64 = conn.pragma_query_value(None, "user_version", |row| row.get(0))?;
assert_eq!(version, 7);
for table in ["sync_heads", "sync_events", "client_sync_state", "user_residency"] {
let exists: i64 = conn.query_row(
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = ?1",
[table],
|row| row.get(0),
)?;
assert_eq!(exists, 1);
}
Ok(())
}
}