1 parent
cfc74d9db6
commit
dacdf9c27d
76 files changed
+5209
-741
No files matched your search
+234
-15
@@ -7,6 +7,7 @@ use std::sync::{Arc, Mutex, OnceLock, mpsc};
|
||||
|
||||
use rusqlite::OptionalExtension as _;
|
||||
use rusqlite::types::{ToSql, ToSqlOutput, Value};
|
||||
use serde::Serialize;
|
||||
use sha2::{Digest, Sha256};
|
||||
use sqlx::pool::PoolConnection;
|
||||
use sqlx::{Executor as _, MySql, MySqlPool, PgPool, Postgres, Row as SqlxRow};
|
||||
@@ -20,18 +21,36 @@ const SQLITE_SCHEMA: &str = include_str!("../../migrations/sqlite/schema.sql");
|
||||
const MYSQL_SCHEMA: &str = include_str!("../../migrations/mysql/schema.sql");
|
||||
const PGSQL_SCHEMA: &str = include_str!("../../migrations/pgsql/schema.sql");
|
||||
|
||||
const SQLITE_MIGRATIONS: &[(u32, &str)] = &[(
|
||||
47,
|
||||
include_str!("../../migrations/sqlite/V047__blob_keys_and_search_outbox.sql"),
|
||||
)];
|
||||
const MYSQL_MIGRATIONS: &[(u32, &str)] = &[(
|
||||
47,
|
||||
include_str!("../../migrations/mysql/V047__blob_keys_and_search_outbox.sql"),
|
||||
)];
|
||||
const PGSQL_MIGRATIONS: &[(u32, &str)] = &[(
|
||||
47,
|
||||
include_str!("../../migrations/pgsql/V047__blob_keys_and_search_outbox.sql"),
|
||||
)];
|
||||
const SQLITE_MIGRATIONS: &[(u32, &str)] = &[
|
||||
(
|
||||
47,
|
||||
include_str!("../../migrations/sqlite/V047__blob_keys_and_search_outbox.sql"),
|
||||
),
|
||||
(
|
||||
48,
|
||||
include_str!("../../migrations/sqlite/V048__folder_counter_generation.sql"),
|
||||
),
|
||||
];
|
||||
const MYSQL_MIGRATIONS: &[(u32, &str)] = &[
|
||||
(
|
||||
47,
|
||||
include_str!("../../migrations/mysql/V047__blob_keys_and_search_outbox.sql"),
|
||||
),
|
||||
(
|
||||
48,
|
||||
include_str!("../../migrations/mysql/V048__folder_counter_generation.sql"),
|
||||
),
|
||||
];
|
||||
const PGSQL_MIGRATIONS: &[(u32, &str)] = &[
|
||||
(
|
||||
47,
|
||||
include_str!("../../migrations/pgsql/V047__blob_keys_and_search_outbox.sql"),
|
||||
),
|
||||
(
|
||||
48,
|
||||
include_str!("../../migrations/pgsql/V048__folder_counter_generation.sql"),
|
||||
),
|
||||
];
|
||||
|
||||
const TENANT_TABLES: &[&str] = &[
|
||||
"calendar_events",
|
||||
@@ -93,6 +112,26 @@ const IMPORT_TABLES: &[&str] = &[
|
||||
"user_meta",
|
||||
];
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct DatabaseDiagnostics {
|
||||
pub backend: String,
|
||||
pub status: String,
|
||||
pub latency_ms: u128,
|
||||
pub pool_size: Option<u32>,
|
||||
pub idle_connections: Option<usize>,
|
||||
pub database_bytes: Option<u64>,
|
||||
pub engine_version: Option<String>,
|
||||
pub account_count: Option<u64>,
|
||||
pub schema_version_min: Option<u32>,
|
||||
pub schema_version_max: Option<u32>,
|
||||
pub schema_aligned: Option<bool>,
|
||||
pub expected_schema_version: u32,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
type SqliteDiagnosticsProbe = (u64, Option<u32>, Option<u32>, Option<u64>, String);
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum DatabaseBackend {
|
||||
Sqlite,
|
||||
@@ -312,6 +351,188 @@ pub fn configured_backend() -> DatabaseBackend {
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn diagnostics() -> DatabaseDiagnostics {
|
||||
let started = std::time::Instant::now();
|
||||
let expected = latest_schema_version();
|
||||
match DATABASE.get() {
|
||||
Some(ConfiguredDatabase::Sqlite { path }) => {
|
||||
let path = path.clone();
|
||||
let result = tokio::task::spawn_blocking(move || -> Result<SqliteDiagnosticsProbe, String> {
|
||||
let conn = rusqlite::Connection::open(&path)
|
||||
.map_err(|e| format!("failed to open SQLite diagnostics connection: {e}"))?;
|
||||
conn.query_row("SELECT 1", [], |_row| Ok(()))
|
||||
.map_err(|e| format!("SQLite probe failed: {e}"))?;
|
||||
let engine_version: String = conn
|
||||
.query_row("SELECT sqlite_version()", [], |row| row.get(0))
|
||||
.map_err(|e| format!("SQLite version probe failed: {e}"))?;
|
||||
let (count, min_version, max_version): (i64, Option<i64>, Option<i64>) = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*), MIN(schema_version), MAX(schema_version) FROM rustbox_accounts",
|
||||
[],
|
||||
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
|
||||
)
|
||||
.map_err(|e| format!("SQLite account diagnostics failed: {e}"))?;
|
||||
let bytes = std::fs::metadata(&path).ok().map(|meta| meta.len());
|
||||
Ok((
|
||||
count.max(0) as u64,
|
||||
min_version.and_then(|value| u32::try_from(value).ok()),
|
||||
max_version.and_then(|value| u32::try_from(value).ok()),
|
||||
bytes,
|
||||
engine_version,
|
||||
))
|
||||
}).await;
|
||||
match result {
|
||||
Ok(Ok((count, min_version, max_version, bytes, engine_version))) => {
|
||||
DatabaseDiagnostics {
|
||||
backend: "sqlite".to_string(),
|
||||
status: "ok".to_string(),
|
||||
latency_ms: started.elapsed().as_millis(),
|
||||
pool_size: None,
|
||||
idle_connections: None,
|
||||
database_bytes: bytes,
|
||||
engine_version: Some(engine_version),
|
||||
account_count: Some(count),
|
||||
schema_version_min: min_version,
|
||||
schema_version_max: max_version,
|
||||
schema_aligned: Some(
|
||||
count == 0
|
||||
|| (min_version == Some(expected) && max_version == Some(expected)),
|
||||
),
|
||||
expected_schema_version: expected,
|
||||
error: None,
|
||||
}
|
||||
}
|
||||
other => DatabaseDiagnostics {
|
||||
backend: "sqlite".to_string(),
|
||||
status: "error".to_string(),
|
||||
latency_ms: started.elapsed().as_millis(),
|
||||
pool_size: None,
|
||||
idle_connections: None,
|
||||
database_bytes: None,
|
||||
engine_version: None,
|
||||
account_count: None,
|
||||
schema_version_min: None,
|
||||
schema_version_max: None,
|
||||
schema_aligned: None,
|
||||
expected_schema_version: expected,
|
||||
error: Some(match other {
|
||||
Ok(Err(error)) => error,
|
||||
Err(error) => format!("SQLite diagnostics task failed: {error}"),
|
||||
Ok(Ok(_)) => unreachable!(),
|
||||
}),
|
||||
},
|
||||
}
|
||||
}
|
||||
Some(ConfiguredDatabase::Mysql { pool, .. }) => {
|
||||
let result: Result<(i64, Option<i64>, Option<i64>, String), String> = async {
|
||||
sqlx::query_scalar::<_, i32>("SELECT 1")
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.map_err(|e| format!("MySQL probe failed: {e}"))?;
|
||||
let engine_version: String = sqlx::query_scalar("SELECT VERSION()")
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.map_err(|e| format!("MySQL version probe failed: {e}"))?;
|
||||
let (count, min, max): (i64, Option<i64>, Option<i64>) = sqlx::query_as("SELECT COUNT(*), MIN(schema_version), MAX(schema_version) FROM rustbox_accounts")
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.map_err(|e| format!("MySQL account diagnostics failed: {e}"))?;
|
||||
Ok((count, min, max, engine_version))
|
||||
}.await;
|
||||
let (status, account_count, min_version, max_version, engine_version, error) =
|
||||
match result {
|
||||
Ok((count, min, max, version)) => (
|
||||
"ok",
|
||||
Some(count.max(0) as u64),
|
||||
min.and_then(|v| u32::try_from(v).ok()),
|
||||
max.and_then(|v| u32::try_from(v).ok()),
|
||||
Some(version),
|
||||
None,
|
||||
),
|
||||
Err(error) => ("error", None, None, None, None, Some(error)),
|
||||
};
|
||||
DatabaseDiagnostics {
|
||||
backend: "mysql".to_string(),
|
||||
status: status.to_string(),
|
||||
latency_ms: started.elapsed().as_millis(),
|
||||
pool_size: Some(pool.size()),
|
||||
idle_connections: Some(pool.num_idle()),
|
||||
database_bytes: None,
|
||||
engine_version,
|
||||
account_count,
|
||||
schema_version_min: min_version,
|
||||
schema_version_max: max_version,
|
||||
schema_aligned: account_count.map(|count| {
|
||||
count == 0 || (min_version == Some(expected) && max_version == Some(expected))
|
||||
}),
|
||||
expected_schema_version: expected,
|
||||
error,
|
||||
}
|
||||
}
|
||||
Some(ConfiguredDatabase::Pgsql { pool, .. }) => {
|
||||
let result: Result<(i64, Option<i64>, Option<i64>, String), String> = async {
|
||||
sqlx::query_scalar::<_, i32>("SELECT 1")
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.map_err(|e| format!("PostgreSQL probe failed: {e}"))?;
|
||||
let engine_version: String = sqlx::query_scalar("SHOW server_version")
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.map_err(|e| format!("PostgreSQL version probe failed: {e}"))?;
|
||||
let (count, min, max): (i64, Option<i64>, Option<i64>) = sqlx::query_as("SELECT COUNT(*), MIN(schema_version), MAX(schema_version) FROM rustbox_accounts")
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.map_err(|e| format!("PostgreSQL account diagnostics failed: {e}"))?;
|
||||
Ok((count, min, max, engine_version))
|
||||
}.await;
|
||||
let (status, account_count, min_version, max_version, engine_version, error) =
|
||||
match result {
|
||||
Ok((count, min, max, version)) => (
|
||||
"ok",
|
||||
Some(count.max(0) as u64),
|
||||
min.and_then(|v| u32::try_from(v).ok()),
|
||||
max.and_then(|v| u32::try_from(v).ok()),
|
||||
Some(version),
|
||||
None,
|
||||
),
|
||||
Err(error) => ("error", None, None, None, None, Some(error)),
|
||||
};
|
||||
DatabaseDiagnostics {
|
||||
backend: "pgsql".to_string(),
|
||||
status: status.to_string(),
|
||||
latency_ms: started.elapsed().as_millis(),
|
||||
pool_size: Some(pool.size()),
|
||||
idle_connections: Some(pool.num_idle()),
|
||||
database_bytes: None,
|
||||
engine_version,
|
||||
account_count,
|
||||
schema_version_min: min_version,
|
||||
schema_version_max: max_version,
|
||||
schema_aligned: account_count.map(|count| {
|
||||
count == 0 || (min_version == Some(expected) && max_version == Some(expected))
|
||||
}),
|
||||
expected_schema_version: expected,
|
||||
error,
|
||||
}
|
||||
}
|
||||
None => DatabaseDiagnostics {
|
||||
backend: "unconfigured".to_string(),
|
||||
status: "error".to_string(),
|
||||
latency_ms: started.elapsed().as_millis(),
|
||||
pool_size: None,
|
||||
idle_connections: None,
|
||||
database_bytes: None,
|
||||
engine_version: None,
|
||||
account_count: None,
|
||||
schema_version_min: None,
|
||||
schema_version_max: None,
|
||||
schema_aligned: None,
|
||||
expected_schema_version: expected,
|
||||
error: Some("database is not configured".to_string()),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub fn list_account_user_hashes() -> Result<Vec<String>, String> {
|
||||
match DATABASE.get() {
|
||||
Some(ConfiguredDatabase::Sqlite { path }) => {
|
||||
@@ -1838,7 +2059,7 @@ fn tenant_prefix(user_hash: &str) -> Result<String, String> {
|
||||
Ok(format!("u_{}__", &normalized[..24]))
|
||||
}
|
||||
|
||||
fn latest_schema_version() -> u32 {
|
||||
pub fn latest_schema_version() -> u32 {
|
||||
let migrations = match configured_backend() {
|
||||
DatabaseBackend::Sqlite => SQLITE_MIGRATIONS,
|
||||
DatabaseBackend::Mysql => MYSQL_MIGRATIONS,
|
||||
@@ -2108,8 +2329,6 @@ fn ensure_server_tenant(
|
||||
let tenant = tenant_schema(PGSQL_SCHEMA, &prefix)?;
|
||||
let legacy_path = legacy_path.clone();
|
||||
runtime_wait(runtime, async move {
|
||||
// PostgreSQL advisory locks are session-scoped, so they work across all RustBox nodes
|
||||
// that share this database and disappear automatically with the connection.
|
||||
let lock_name = format!("rustbox:{prefix}");
|
||||
let mut lock_conn = pool.acquire().await.map_err(|e| {
|
||||
format!("failed to acquire PostgreSQL provisioning connection: {e}")
|
||||
|
||||
Reference in new issue
Block a user