v0.2.8
RustBox CI / rustbox (push) Canceled after 0s

This commit is contained in:
Mateusz Gruszczyński committed 2026-11-01 20:40:40 +01:00
1 parent cfc74d9db6
commit dacdf9c27d
76 files changed
+5209 -741

No files matched your search

+234 -15
View File
@@ -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}")