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

+124 -21
View File
@@ -12,6 +12,7 @@ pub struct CachedFolder {
pub unread_count: u32,
pub uid_validity: u32,
pub highest_modseq: u64,
pub counter_generation: u64,
}
#[allow(clippy::too_many_arguments)]
@@ -68,6 +69,7 @@ pub fn get_all_folders(conn: &Connection) -> Result<Vec<CachedFolder>, String> {
.query_map([], |row| {
let is_subscribed_int: i32 = row.get(4)?;
let highest_modseq_int: i64 = row.get(8)?;
let counter_generation_int: i64 = row.get(9)?;
Ok(CachedFolder {
name: row.get(0)?,
delimiter: row.get(1)?,
@@ -78,6 +80,7 @@ pub fn get_all_folders(conn: &Connection) -> Result<Vec<CachedFolder>, String> {
unread_count: row.get(6)?,
uid_validity: row.get(7)?,
highest_modseq: highest_modseq_int as u64,
counter_generation: counter_generation_int as u64,
})
})
.map_err(|e| format!("Failed to query folders: {e}"))?;
@@ -118,6 +121,7 @@ pub fn touch_all_folders(conn: &Connection) -> Result<(), String> {
Ok(())
}
#[cfg(test)]
pub fn update_folder_status(
conn: &Connection,
name: &str,
@@ -147,21 +151,6 @@ pub fn update_folder_sync_status(
Ok(())
}
pub fn refresh_unread_count(conn: &Connection, folder_name: &str) -> Result<(), String> {
let unread: u32 = conn
.query_row(
crate::dbq!(db_folders::Q_008),
params![folder_name],
|row| row.get(0),
)
.map_err(|e| format!("Failed to count unread messages: {e}"))?;
conn.execute(crate::dbq!(db_folders::Q_009), params![unread, folder_name])
.map_err(|e| format!("Failed to update unread_count: {e}"))?;
Ok(())
}
pub fn set_unread_count(
conn: &Connection,
folder_name: &str,
@@ -175,15 +164,74 @@ pub fn set_unread_count(
Ok(())
}
pub fn mark_folder_messages_fresh(conn: &Connection, folder_name: &str) -> Result<(), String> {
conn.execute(crate::dbq!(db_folders::Q_029), params![folder_name])
.map_err(|e| format!("Failed to mark folder messages fresh: {e}"))?;
pub fn set_folder_counts(
conn: &Connection,
folder_name: &str,
total_count: u32,
unread_count: u32,
) -> Result<(), String> {
conn.execute(
crate::dbq!(db_folders::Q_030),
params![total_count, unread_count, folder_name],
)
.map_err(|e| format!("Failed to set folder counts: {e}"))?;
Ok(())
}
pub fn adjust_unread_count(conn: &Connection, folder_name: &str, delta: i32) -> Result<(), String> {
conn.execute(crate::dbq!(db_folders::Q_010), params![delta, folder_name])
.map_err(|e| format!("Failed to adjust unread_count: {e}"))?;
pub fn update_folder_sync_cursor(
conn: &Connection,
folder_name: &str,
uid_validity: u32,
highest_modseq: u64,
) -> Result<(), String> {
conn.execute(
crate::dbq!(db_folders::Q_031),
params![uid_validity, highest_modseq as i64, folder_name],
)
.map_err(|e| format!("Failed to update folder sync cursor: {e}"))?;
Ok(())
}
pub fn set_folder_counts_if_unchanged(
conn: &Connection,
folder_name: &str,
expected_generation: u64,
total_count: u32,
unread_count: u32,
) -> Result<bool, String> {
let changed = conn
.execute(
crate::dbq!(db_folders::Q_032),
params![
total_count,
unread_count,
folder_name,
expected_generation as i64
],
)
.map_err(|e| format!("Failed to conditionally set folder counts: {e}"))?;
Ok(changed > 0)
}
pub fn pending_message_sync_count(conn: &Connection) -> Result<u32, String> {
let mut pending = 0u64;
for folder in get_all_folders(conn)? {
if folder
.flags
.split(',')
.any(|flag| flag.trim().eq_ignore_ascii_case("\\Noselect"))
{
continue;
}
let cached = crate::db::messages::count_messages(conn, &folder.name)?;
pending = pending.saturating_add(folder.total_count.saturating_sub(cached) as u64);
}
Ok(pending.min(u32::MAX as u64) as u32)
}
pub fn mark_folder_messages_fresh(conn: &Connection, folder_name: &str) -> Result<(), String> {
conn.execute(crate::dbq!(db_folders::Q_029), params![folder_name])
.map_err(|e| format!("Failed to mark folder messages fresh: {e}"))?;
Ok(())
}
@@ -237,6 +285,7 @@ pub fn get_folder(conn: &Connection, name: &str) -> Result<Option<CachedFolder>,
let result = conn.query_row(crate::dbq!(db_folders::Q_015), params![name], |row| {
let is_subscribed_int: i32 = row.get(4)?;
let highest_modseq_int: i64 = row.get(8)?;
let counter_generation_int: i64 = row.get(9)?;
Ok(CachedFolder {
name: row.get(0)?,
delimiter: row.get(1)?,
@@ -247,6 +296,7 @@ pub fn get_folder(conn: &Connection, name: &str) -> Result<Option<CachedFolder>,
unread_count: row.get(6)?,
uid_validity: row.get(7)?,
highest_modseq: highest_modseq_int as u64,
counter_generation: counter_generation_int as u64,
})
});
@@ -433,6 +483,59 @@ mod tests {
assert!(!is_folder_fresh(&conn, "Sent", 30).unwrap());
}
#[test]
fn conditional_folder_counts_reject_stale_writer() {
let conn = open_test_db();
upsert_folder(&conn, "INBOX", None, None, "", true, 3444, 12, 1, 50).unwrap();
let generation = get_folder(&conn, "INBOX")
.unwrap()
.unwrap()
.counter_generation;
assert!(set_folder_counts_if_unchanged(&conn, "INBOX", generation, 3444, 11).unwrap());
assert!(!set_folder_counts_if_unchanged(&conn, "INBOX", generation, 3444, 10).unwrap());
let folder = get_folder(&conn, "INBOX").unwrap().unwrap();
assert_eq!(folder.total_count, 3444);
assert_eq!(folder.unread_count, 11);
}
#[test]
fn counter_generation_blocks_aba_stale_writer() {
let conn = open_test_db();
upsert_folder(&conn, "INBOX", None, None, "", true, 3444, 12, 1, 50).unwrap();
let generation_12 = get_folder(&conn, "INBOX")
.unwrap()
.unwrap()
.counter_generation;
assert!(set_folder_counts_if_unchanged(&conn, "INBOX", generation_12, 3444, 11).unwrap());
let generation_11 = get_folder(&conn, "INBOX")
.unwrap()
.unwrap()
.counter_generation;
assert!(set_folder_counts_if_unchanged(&conn, "INBOX", generation_11, 3444, 12).unwrap());
assert!(!set_folder_counts_if_unchanged(&conn, "INBOX", generation_12, 3444, 10).unwrap());
let folder = get_folder(&conn, "INBOX").unwrap().unwrap();
assert_eq!((folder.total_count, folder.unread_count), (3444, 12));
assert!(folder.counter_generation > generation_12);
}
#[test]
fn sync_cursor_update_does_not_change_authoritative_counts() {
let conn = open_test_db();
upsert_folder(&conn, "INBOX", None, None, "", true, 3444, 12, 1, 50).unwrap();
update_folder_sync_cursor(&conn, "INBOX", 2, 75).unwrap();
let folder = get_folder(&conn, "INBOX").unwrap().unwrap();
assert_eq!(folder.total_count, 3444);
assert_eq!(folder.unread_count, 12);
assert_eq!(folder.uid_validity, 2);
assert_eq!(folder.highest_modseq, 75);
}
#[test]
fn test_remove_stale_folders() {
let conn = open_test_db();
+5
View File
@@ -476,6 +476,11 @@ pub fn get_unindexed_messages(conn: &Connection, limit: u32) -> Result<Vec<(Stri
Ok(result)
}
pub fn count_unindexed_messages(conn: &Connection) -> Result<u32, String> {
conn.query_row(crate::dbq!(db_messages::Q_015), [], |row| row.get(0))
.map_err(|e| format!("Failed to count unindexed messages: {e}"))
}
pub fn get_all_uids_and_flags(
conn: &Connection,
folder: &str,
+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}")