v0.2.0
This commit is contained in:
1 parent
fc81ecafd6
commit
dc57aa7089
89 files changed
+5290
-1551
No files matched your search
@@ -491,7 +491,7 @@ pub fn update_preferences(
|
||||
set_bool!(attachment_reminder, "attachment_reminder");
|
||||
set_bool!(deep_index, "deep_index");
|
||||
if let Some(ref value) = data.animation_mode {
|
||||
sets.push(format!("animation_mode = ?{}", idx));
|
||||
sets.push(format!("animation_mode = ?{idx}"));
|
||||
values.push(value.to_db_value());
|
||||
idx += 1;
|
||||
}
|
||||
|
||||
+85
-40
@@ -33,7 +33,8 @@ pub struct DraftAttachment {
|
||||
pub filename: String,
|
||||
pub content_type: String,
|
||||
pub size: i64,
|
||||
pub file_path: String,
|
||||
pub storage_key: String,
|
||||
pub checksum_sha256: Option<String>,
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
@@ -196,18 +197,31 @@ pub fn delete_draft(conn: &Connection, id: &str) -> Result<bool, String> {
|
||||
Ok(deleted > 0)
|
||||
}
|
||||
|
||||
pub struct NewDraftAttachment<'a> {
|
||||
pub id: &'a str,
|
||||
pub draft_id: &'a str,
|
||||
pub filename: &'a str,
|
||||
pub content_type: &'a str,
|
||||
pub size: i64,
|
||||
pub storage_key: &'a str,
|
||||
pub checksum_sha256: Option<&'a str>,
|
||||
}
|
||||
|
||||
pub fn add_draft_attachment(
|
||||
conn: &Connection,
|
||||
id: &str,
|
||||
draft_id: &str,
|
||||
filename: &str,
|
||||
content_type: &str,
|
||||
size: i64,
|
||||
file_path: &str,
|
||||
attachment: &NewDraftAttachment<'_>,
|
||||
) -> Result<(), String> {
|
||||
conn.execute(
|
||||
crate::dbq!(db_drafts::Q_006),
|
||||
params![id, draft_id, filename, content_type, size, file_path],
|
||||
params![
|
||||
attachment.id,
|
||||
attachment.draft_id,
|
||||
attachment.filename,
|
||||
attachment.content_type,
|
||||
attachment.size,
|
||||
attachment.storage_key,
|
||||
attachment.checksum_sha256
|
||||
],
|
||||
)
|
||||
.map_err(|e| format!("Failed to add draft attachment: {e}"))?;
|
||||
Ok(())
|
||||
@@ -229,8 +243,9 @@ pub fn get_draft_attachments(
|
||||
filename: row.get(2)?,
|
||||
content_type: row.get(3)?,
|
||||
size: row.get(4)?,
|
||||
file_path: row.get(5)?,
|
||||
created_at: row.get(6)?,
|
||||
storage_key: row.get(5)?,
|
||||
checksum_sha256: row.get(6)?,
|
||||
created_at: row.get(7)?,
|
||||
})
|
||||
})
|
||||
.map_err(|e| format!("Failed to query draft attachments: {e}"))?;
|
||||
@@ -249,6 +264,21 @@ pub fn delete_draft_attachment(conn: &Connection, id: &str) -> Result<bool, Stri
|
||||
Ok(deleted > 0)
|
||||
}
|
||||
|
||||
pub fn update_attachment_storage(
|
||||
conn: &Connection,
|
||||
id: &str,
|
||||
storage_key: &str,
|
||||
checksum_sha256: Option<&str>,
|
||||
) -> Result<bool, String> {
|
||||
let updated = conn
|
||||
.execute(
|
||||
crate::dbq!(db_drafts::Q_009),
|
||||
params![storage_key, checksum_sha256, id],
|
||||
)
|
||||
.map_err(|e| format!("Failed to update draft attachment storage: {e}"))?;
|
||||
Ok(updated > 0)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -431,23 +461,29 @@ mod tests {
|
||||
|
||||
add_draft_attachment(
|
||||
&conn,
|
||||
"att-1",
|
||||
"draft-1",
|
||||
"file.pdf",
|
||||
"application/pdf",
|
||||
1024,
|
||||
"/data/abc/attachments/draft-1/att-1",
|
||||
&NewDraftAttachment {
|
||||
id: "att-1",
|
||||
draft_id: "draft-1",
|
||||
filename: "file.pdf",
|
||||
content_type: "application/pdf",
|
||||
size: 1024,
|
||||
storage_key: "/data/abc/attachments/draft-1/att-1",
|
||||
checksum_sha256: None,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
add_draft_attachment(
|
||||
&conn,
|
||||
"att-2",
|
||||
"draft-1",
|
||||
"photo.jpg",
|
||||
"image/jpeg",
|
||||
2048,
|
||||
"/data/abc/attachments/draft-1/att-2",
|
||||
&NewDraftAttachment {
|
||||
id: "att-2",
|
||||
draft_id: "draft-1",
|
||||
filename: "photo.jpg",
|
||||
content_type: "image/jpeg",
|
||||
size: 2048,
|
||||
storage_key: "/data/abc/attachments/draft-1/att-2",
|
||||
checksum_sha256: None,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
@@ -469,12 +505,15 @@ mod tests {
|
||||
.unwrap();
|
||||
add_draft_attachment(
|
||||
&conn,
|
||||
"att-1",
|
||||
"draft-1",
|
||||
"file.pdf",
|
||||
"application/pdf",
|
||||
100,
|
||||
"/path",
|
||||
&NewDraftAttachment {
|
||||
id: "att-1",
|
||||
draft_id: "draft-1",
|
||||
filename: "file.pdf",
|
||||
content_type: "application/pdf",
|
||||
size: 100,
|
||||
storage_key: "/path",
|
||||
checksum_sha256: None,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
@@ -496,22 +535,28 @@ mod tests {
|
||||
.unwrap();
|
||||
add_draft_attachment(
|
||||
&conn,
|
||||
"att-1",
|
||||
"draft-1",
|
||||
"file.pdf",
|
||||
"application/pdf",
|
||||
100,
|
||||
"/path",
|
||||
&NewDraftAttachment {
|
||||
id: "att-1",
|
||||
draft_id: "draft-1",
|
||||
filename: "file.pdf",
|
||||
content_type: "application/pdf",
|
||||
size: 100,
|
||||
storage_key: "/path",
|
||||
checksum_sha256: None,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
add_draft_attachment(
|
||||
&conn,
|
||||
"att-2",
|
||||
"draft-1",
|
||||
"pic.jpg",
|
||||
"image/jpeg",
|
||||
200,
|
||||
"/path2",
|
||||
&NewDraftAttachment {
|
||||
id: "att-2",
|
||||
draft_id: "draft-1",
|
||||
filename: "pic.jpg",
|
||||
content_type: "image/jpeg",
|
||||
size: 200,
|
||||
storage_key: "/path2",
|
||||
checksum_sha256: None,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
|
||||
@@ -232,7 +232,7 @@ pub fn update_identity(
|
||||
boolean!(signature_forward, "signature_forward");
|
||||
text!(signature_position, "signature_position");
|
||||
if let Some(ref value) = data.pgp_fingerprint {
|
||||
sets.push(format!("pgp_fingerprint = ?{}", idx));
|
||||
sets.push(format!("pgp_fingerprint = ?{idx}"));
|
||||
values.push(value.to_db_value());
|
||||
idx += 1;
|
||||
}
|
||||
|
||||
+15
-11
@@ -1,5 +1,5 @@
|
||||
use crate::db::pool::{
|
||||
Connection, DatabaseBackend, DbValue, ToDbValue, configured_backend, params,
|
||||
Connection, DatabaseBackend, DbValue, ToDbValue, Transaction, configured_backend, params,
|
||||
};
|
||||
use crate::queries::sqlite::queries::db_messages::MSG_SELECT_COLS;
|
||||
use serde::Serialize;
|
||||
@@ -108,11 +108,20 @@ pub fn upsert_message(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn remove_after_mutation_in_transaction(
|
||||
transaction: &Transaction,
|
||||
folder: &str,
|
||||
uid: u32,
|
||||
) -> Result<(), String> {
|
||||
delete_message(transaction, folder, uid)?;
|
||||
super::header_sync::invalidate(transaction)?;
|
||||
super::folders::invalidate_folder_freshness(transaction, folder)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn remove_after_mutation(conn: &Connection, folder: &str, uid: u32) -> Result<(), String> {
|
||||
let transaction = conn.unchecked_transaction().map_err(|e| e.to_string())?;
|
||||
delete_message(&transaction, folder, uid)?;
|
||||
super::header_sync::invalidate(&transaction)?;
|
||||
super::folders::invalidate_folder_freshness(&transaction, folder)?;
|
||||
remove_after_mutation_in_transaction(&transaction, folder, uid)?;
|
||||
transaction.commit().map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
@@ -495,7 +504,7 @@ pub fn get_thread_messages(
|
||||
.replace('!', "!!")
|
||||
.replace("%", "!%")
|
||||
.replace("_", "!_");
|
||||
let like_pattern = format!("%{}%", escaped_target);
|
||||
let like_pattern = format!("%{escaped_target}%");
|
||||
let sql = crate::dbq!(db_messages::FORMAT_004).replace("{MSG_SELECT_COLS}", MSG_SELECT_COLS);
|
||||
|
||||
let mut stmt = conn
|
||||
@@ -815,12 +824,7 @@ mod tests {
|
||||
ensure_folder(&conn, "INBOX");
|
||||
|
||||
for uid in 1..=5 {
|
||||
insert_sample(
|
||||
&conn,
|
||||
"INBOX",
|
||||
uid,
|
||||
&format!("2024-01-{:02}T10:00:00Z", uid),
|
||||
);
|
||||
insert_sample(&conn, "INBOX", uid, &format!("2024-01-{uid:02}T10:00:00Z"));
|
||||
}
|
||||
|
||||
let page0 = get_messages(&conn, "INBOX", 0, 2).unwrap();
|
||||
|
||||
@@ -13,6 +13,7 @@ pub mod notification_preferences;
|
||||
pub mod pgp;
|
||||
pub mod pool;
|
||||
pub mod response_templates;
|
||||
pub mod search_outbox;
|
||||
pub mod special_folders;
|
||||
pub mod tags;
|
||||
pub mod trusted_senders;
|
||||
|
||||
+78
-19
@@ -20,10 +20,20 @@ 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");
|
||||
|
||||
// Future migrations start at V047. Each entry must exist for every supported engine.
|
||||
const SQLITE_MIGRATIONS: &[(u32, &str)] = &[];
|
||||
const MYSQL_MIGRATIONS: &[(u32, &str)] = &[];
|
||||
const PGSQL_MIGRATIONS: &[(u32, &str)] = &[];
|
||||
// The squashed legacy baseline is V046. Runtime migrations start at V047 and
|
||||
// are stored as real files for every supported SQL engine.
|
||||
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 TENANT_TABLES: &[&str] = &[
|
||||
"calendar_events",
|
||||
@@ -48,6 +58,7 @@ const TENANT_TABLES: &[&str] = &[
|
||||
"pgp_preferences",
|
||||
"response_templates",
|
||||
"saved_searches",
|
||||
"search_outbox",
|
||||
"special_folders",
|
||||
"tags",
|
||||
"trusted_senders",
|
||||
@@ -79,6 +90,7 @@ const IMPORT_TABLES: &[&str] = &[
|
||||
"pgp_preferences",
|
||||
"response_templates",
|
||||
"saved_searches",
|
||||
"search_outbox",
|
||||
"special_folders",
|
||||
"trusted_senders",
|
||||
"user_meta",
|
||||
@@ -96,7 +108,7 @@ impl DatabaseBackend {
|
||||
match value.trim().to_ascii_lowercase().as_str() {
|
||||
"sqlite" => Ok(Self::Sqlite),
|
||||
"mysql" => Ok(Self::Mysql),
|
||||
"pgsql" | "postgres" | "postgresql" => Ok(Self::Pgsql),
|
||||
"pgsql" => Ok(Self::Pgsql),
|
||||
other => Err(format!("unsupported database backend: {other}")),
|
||||
}
|
||||
}
|
||||
@@ -291,6 +303,46 @@ pub fn configured_backend() -> DatabaseBackend {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn list_account_user_hashes() -> Result<Vec<String>, String> {
|
||||
match DATABASE.get() {
|
||||
Some(ConfiguredDatabase::Sqlite { path }) => {
|
||||
let conn = rusqlite::Connection::open(path)
|
||||
.map_err(|e| format!("failed to open SQLite account registry: {e}"))?;
|
||||
let mut stmt = conn
|
||||
.prepare("SELECT user_hash FROM rustbox_accounts ORDER BY user_hash")
|
||||
.map_err(|e| format!("failed to prepare account registry query: {e}"))?;
|
||||
let rows = stmt
|
||||
.query_map([], |row| row.get::<_, String>(0))
|
||||
.map_err(|e| format!("failed to query account registry: {e}"))?;
|
||||
rows.map(|row| row.map_err(|e| format!("failed to read account registry row: {e}")))
|
||||
.collect()
|
||||
}
|
||||
Some(ConfiguredDatabase::Mysql { pool, runtime }) => {
|
||||
let pool = pool.clone();
|
||||
runtime_wait(runtime, async move {
|
||||
sqlx::query_scalar::<_, String>(
|
||||
"SELECT user_hash FROM rustbox_accounts ORDER BY user_hash",
|
||||
)
|
||||
.fetch_all(&pool)
|
||||
.await
|
||||
.map_err(|e| format!("failed to query MySQL account registry: {e}"))
|
||||
})
|
||||
}
|
||||
Some(ConfiguredDatabase::Pgsql { pool, runtime }) => {
|
||||
let pool = pool.clone();
|
||||
runtime_wait(runtime, async move {
|
||||
sqlx::query_scalar::<_, String>(
|
||||
"SELECT user_hash FROM rustbox_accounts ORDER BY user_hash",
|
||||
)
|
||||
.fetch_all(&pool)
|
||||
.await
|
||||
.map_err(|e| format!("failed to query PostgreSQL account registry: {e}"))
|
||||
})
|
||||
}
|
||||
None => Err("database is not configured".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
#[macro_export]
|
||||
macro_rules! dbq {
|
||||
($module:ident::$name:ident) => {{
|
||||
@@ -1562,12 +1614,16 @@ fn read_legacy_sqlite(path: &Path) -> Result<Vec<LegacyTableData>, String> {
|
||||
.map_err(|e| format!("failed to read legacy SQLite schema version: {e}"))?;
|
||||
if version < BASE_SCHEMA_VERSION {
|
||||
return Err(format!(
|
||||
"legacy SQLite schema version {version} is older than the RustBox 0.1.2 baseline {BASE_SCHEMA_VERSION}; upgrade with RustBox 0.1.0 first"
|
||||
"legacy SQLite schema version {version} is older than the legacy RustBox baseline {BASE_SCHEMA_VERSION}; upgrade with RustBox 0.1.0 first"
|
||||
));
|
||||
}
|
||||
if version > BASE_SCHEMA_VERSION {
|
||||
let latest_supported_sqlite = SQLITE_MIGRATIONS
|
||||
.last()
|
||||
.map(|(migration_version, _)| *migration_version)
|
||||
.unwrap_or(BASE_SCHEMA_VERSION);
|
||||
if version > latest_supported_sqlite {
|
||||
return Err(format!(
|
||||
"legacy SQLite schema version {version} is newer than the RustBox 0.1.2 baseline {BASE_SCHEMA_VERSION}"
|
||||
"legacy SQLite schema version {version} is newer than the latest SQLite schema supported by this build ({latest_supported_sqlite})"
|
||||
));
|
||||
}
|
||||
let mut output = Vec::new();
|
||||
@@ -1904,7 +1960,7 @@ fn ensure_sqlite_tenant(
|
||||
.map_err(|e| format!("failed to initialize SQLite tenant defaults: {e}"))?;
|
||||
tx.execute(
|
||||
"INSERT INTO rustbox_accounts(user_hash, table_prefix, schema_version) VALUES(?1, ?2, ?3)",
|
||||
rusqlite::params![user_hash, prefix, BASE_SCHEMA_VERSION],
|
||||
rusqlite::params![user_hash, prefix, latest_schema_version()],
|
||||
)
|
||||
.map_err(|e| format!("failed to register SQLite account schema: {e}"))?;
|
||||
tx.commit().map_err(|e| e.to_string())?;
|
||||
@@ -1922,7 +1978,7 @@ fn apply_sqlite_migrations(
|
||||
) -> Result<(), String> {
|
||||
if current < BASE_SCHEMA_VERSION {
|
||||
return Err(format!(
|
||||
"database schema version {current} is older than the RustBox 0.1.2 baseline {BASE_SCHEMA_VERSION}; upgrade with RustBox 0.1.0 first"
|
||||
"database schema version {current} is older than the legacy RustBox baseline {BASE_SCHEMA_VERSION}; upgrade with RustBox 0.1.0 first"
|
||||
));
|
||||
}
|
||||
let latest = latest_schema_version();
|
||||
@@ -1967,7 +2023,8 @@ fn ensure_server_tenant(
|
||||
user_hash: &str,
|
||||
prefix: &str,
|
||||
) -> Result<(), String> {
|
||||
let legacy_path = Path::new(data_dir).join(user_hash).join("db.sqlite");
|
||||
let legacy_path = (!data_dir.trim().is_empty())
|
||||
.then(|| Path::new(data_dir).join(user_hash).join("db.sqlite"));
|
||||
|
||||
let imported_legacy = match configured {
|
||||
ConfiguredDatabase::Mysql { pool, runtime } => {
|
||||
@@ -2015,8 +2072,8 @@ fn ensure_server_tenant(
|
||||
.bind(&prefix).fetch_optional(&pool).await.map_err(|e| e.to_string())?;
|
||||
if collision.is_some() { return Err("database account-prefix collision detected".into()); }
|
||||
|
||||
let legacy_data = if legacy_path.is_file() {
|
||||
read_legacy_sqlite(&legacy_path)?
|
||||
let legacy_data = if let Some(path) = legacy_path.as_ref().filter(|path| path.is_file()) {
|
||||
read_legacy_sqlite(path)?
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
@@ -2035,7 +2092,7 @@ fn ensure_server_tenant(
|
||||
return Err(format!("failed to initialize MySQL tenant defaults: {error}"));
|
||||
}
|
||||
if let Err(error) = sqlx::query("INSERT INTO rustbox_accounts(user_hash, table_prefix, schema_version) VALUES(?, ?, ?)")
|
||||
.bind(&user_hash).bind(&prefix).bind(i64::from(BASE_SCHEMA_VERSION)).execute(&pool).await
|
||||
.bind(&user_hash).bind(&prefix).bind(i64::from(latest_schema_version())).execute(&pool).await
|
||||
{
|
||||
drop_mysql_tenant(&pool, &prefix).await;
|
||||
return Err(format!("failed to register MySQL account schema: {error}"));
|
||||
@@ -2090,8 +2147,8 @@ fn ensure_server_tenant(
|
||||
.bind(&prefix).fetch_optional(&pool).await.map_err(|e| e.to_string())?;
|
||||
if collision.is_some() { return Err("database account-prefix collision detected".into()); }
|
||||
|
||||
let legacy_data = if legacy_path.is_file() {
|
||||
read_legacy_sqlite(&legacy_path)?
|
||||
let legacy_data = if let Some(path) = legacy_path.as_ref().filter(|path| path.is_file()) {
|
||||
read_legacy_sqlite(path)?
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
@@ -2110,7 +2167,7 @@ fn ensure_server_tenant(
|
||||
return Err(format!("failed to initialize PostgreSQL tenant defaults: {error}"));
|
||||
}
|
||||
if let Err(error) = sqlx::query("INSERT INTO rustbox_accounts(user_hash, table_prefix, schema_version) VALUES($1, $2, $3)")
|
||||
.bind(&user_hash).bind(&prefix).bind(i64::from(BASE_SCHEMA_VERSION)).execute(&pool).await
|
||||
.bind(&user_hash).bind(&prefix).bind(i64::from(latest_schema_version())).execute(&pool).await
|
||||
{
|
||||
drop_pgsql_tenant(&pool, &prefix).await;
|
||||
return Err(format!("failed to register PostgreSQL account schema: {error}"));
|
||||
@@ -2128,7 +2185,9 @@ fn ensure_server_tenant(
|
||||
};
|
||||
|
||||
if imported_legacy {
|
||||
backup_legacy_sqlite(&legacy_path);
|
||||
if let Some(path) = legacy_path.as_ref() {
|
||||
backup_legacy_sqlite(path);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -2215,7 +2274,7 @@ pub fn open_test_db() -> Connection {
|
||||
.unwrap();
|
||||
raw.execute(
|
||||
"INSERT INTO rustbox_accounts(user_hash, table_prefix, schema_version) VALUES(?1, ?2, ?3)",
|
||||
rusqlite::params![user_hash, prefix, BASE_SCHEMA_VERSION],
|
||||
rusqlite::params![user_hash, prefix, latest_schema_version()],
|
||||
)
|
||||
.unwrap();
|
||||
Connection {
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use crate::db::pool::{Connection, params};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct OutboxEvent {
|
||||
pub id: String,
|
||||
pub generation: String,
|
||||
pub event_type: String,
|
||||
pub message_uid: u32,
|
||||
pub folder: String,
|
||||
pub payload: Option<String>,
|
||||
pub attempts: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct OutboxStats {
|
||||
pub total: usize,
|
||||
pub dead_letters: usize,
|
||||
pub lag_seconds: u64,
|
||||
}
|
||||
|
||||
pub fn now_epoch() -> i64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs()
|
||||
.min(i64::MAX as u64) as i64
|
||||
}
|
||||
|
||||
pub fn enqueue(
|
||||
conn: &Connection,
|
||||
id: &str,
|
||||
event_type: &str,
|
||||
message_uid: u32,
|
||||
folder: &str,
|
||||
payload: Option<&str>,
|
||||
) -> Result<String, String> {
|
||||
let generation = uuid::Uuid::new_v4().to_string();
|
||||
let now = now_epoch();
|
||||
let result = match crate::db::pool::configured_backend() {
|
||||
crate::db::pool::DatabaseBackend::Mysql => conn.execute(
|
||||
crate::dbq!(db_search_outbox::Q_001),
|
||||
params![
|
||||
id,
|
||||
&generation,
|
||||
event_type,
|
||||
message_uid,
|
||||
folder,
|
||||
payload,
|
||||
now,
|
||||
now
|
||||
],
|
||||
),
|
||||
_ => conn.execute(
|
||||
crate::dbq!(db_search_outbox::Q_001),
|
||||
params![
|
||||
id,
|
||||
&generation,
|
||||
event_type,
|
||||
message_uid,
|
||||
folder,
|
||||
payload,
|
||||
now
|
||||
],
|
||||
),
|
||||
};
|
||||
result.map_err(|e| format!("failed to enqueue search event: {e}"))?;
|
||||
Ok(generation)
|
||||
}
|
||||
|
||||
pub fn list_ready(conn: &Connection, limit: usize) -> Result<Vec<OutboxEvent>, String> {
|
||||
let now = now_epoch();
|
||||
let mut stmt = conn
|
||||
.prepare(crate::dbq!(db_search_outbox::Q_002))
|
||||
.map_err(|e| format!("failed to prepare search outbox query: {e}"))?;
|
||||
let ready_params = match crate::db::pool::configured_backend() {
|
||||
crate::db::pool::DatabaseBackend::Mysql => params![now, now, limit],
|
||||
_ => params![now, limit],
|
||||
};
|
||||
let rows = stmt
|
||||
.query_map(ready_params, |row| {
|
||||
Ok(OutboxEvent {
|
||||
id: row.get(0)?,
|
||||
generation: row.get(1)?,
|
||||
event_type: row.get(2)?,
|
||||
message_uid: row.get::<_, i64>(3)?.max(0) as u32,
|
||||
folder: row.get(4)?,
|
||||
payload: row.get(5)?,
|
||||
attempts: row.get::<_, i64>(6)?.max(0) as u32,
|
||||
})
|
||||
})
|
||||
.map_err(|e| format!("failed to query search outbox: {e}"))?;
|
||||
rows.map(|row| row.map_err(|e| format!("failed to read search outbox row: {e}")))
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn claim(conn: &Connection, event: &OutboxEvent, lease_secs: u64) -> Result<bool, String> {
|
||||
let now = now_epoch();
|
||||
let lease_until = now.saturating_add(lease_secs.max(1) as i64);
|
||||
let changed = match crate::db::pool::configured_backend() {
|
||||
crate::db::pool::DatabaseBackend::Mysql => conn.execute(
|
||||
crate::dbq!(db_search_outbox::Q_006),
|
||||
params![lease_until, &event.id, &event.generation, now, now],
|
||||
),
|
||||
_ => conn.execute(
|
||||
crate::dbq!(db_search_outbox::Q_006),
|
||||
params![&event.id, &event.generation, lease_until, now],
|
||||
),
|
||||
}
|
||||
.map_err(|e| format!("failed to claim search event: {e}"))?;
|
||||
Ok(changed > 0)
|
||||
}
|
||||
|
||||
pub fn mark_success(conn: &Connection, event: &OutboxEvent) -> Result<bool, String> {
|
||||
let changed = conn
|
||||
.execute(
|
||||
crate::dbq!(db_search_outbox::Q_003),
|
||||
params![&event.id, &event.generation],
|
||||
)
|
||||
.map_err(|e| format!("failed to acknowledge search event: {e}"))?;
|
||||
Ok(changed > 0)
|
||||
}
|
||||
|
||||
pub fn mark_failure(
|
||||
conn: &Connection,
|
||||
event: &OutboxEvent,
|
||||
error: &str,
|
||||
max_attempts: u32,
|
||||
) -> Result<bool, String> {
|
||||
let attempts = event.attempts.saturating_add(1);
|
||||
let dead = attempts >= max_attempts;
|
||||
let backoff = 2u64.saturating_pow(attempts.min(10)).min(900);
|
||||
let available_at = now_epoch().saturating_add(backoff as i64);
|
||||
let now = now_epoch();
|
||||
let truncated: String = error.chars().take(2048).collect();
|
||||
let changed = match crate::db::pool::configured_backend() {
|
||||
crate::db::pool::DatabaseBackend::Mysql => conn.execute(
|
||||
crate::dbq!(db_search_outbox::Q_004),
|
||||
params![
|
||||
attempts,
|
||||
available_at,
|
||||
dead,
|
||||
&truncated,
|
||||
now,
|
||||
&event.id,
|
||||
&event.generation
|
||||
],
|
||||
),
|
||||
_ => conn.execute(
|
||||
crate::dbq!(db_search_outbox::Q_004),
|
||||
params![
|
||||
&event.id,
|
||||
&event.generation,
|
||||
attempts,
|
||||
available_at,
|
||||
dead,
|
||||
&truncated,
|
||||
now
|
||||
],
|
||||
),
|
||||
}
|
||||
.map_err(|e| format!("failed to update search retry state: {e}"))?;
|
||||
Ok(changed > 0)
|
||||
}
|
||||
|
||||
pub fn clear_before(conn: &Connection, updated_before: i64) -> Result<usize, String> {
|
||||
conn.execute(
|
||||
crate::dbq!(db_search_outbox::Q_007),
|
||||
params![updated_before],
|
||||
)
|
||||
.map_err(|e| format!("failed to clear superseded search events: {e}"))
|
||||
}
|
||||
|
||||
pub fn stats(conn: &Connection) -> Result<OutboxStats, String> {
|
||||
let (total, dead, oldest): (i64, i64, i64) = conn
|
||||
.query_row(crate::dbq!(db_search_outbox::Q_005), [], |row| {
|
||||
Ok((row.get(0)?, row.get(1)?, row.get(2)?))
|
||||
})
|
||||
.map_err(|e| format!("failed to read search outbox stats: {e}"))?;
|
||||
let now = now_epoch();
|
||||
Ok(OutboxStats {
|
||||
total: total.max(0) as usize,
|
||||
dead_letters: dead.max(0) as usize,
|
||||
lag_seconds: if oldest > 0 {
|
||||
now.saturating_sub(oldest) as u64
|
||||
} else {
|
||||
0
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::db::pool::open_test_db;
|
||||
|
||||
#[test]
|
||||
fn enqueue_is_idempotent_per_document_and_generation_safe() {
|
||||
let conn = open_test_db();
|
||||
let first =
|
||||
enqueue(&conn, "doc-1", "upsert", 7, "INBOX", Some("{}")).expect("first enqueue");
|
||||
let second = enqueue(&conn, "doc-1", "delete", 7, "INBOX", None).expect("second enqueue");
|
||||
assert_ne!(first, second);
|
||||
let events = list_ready(&conn, 10).unwrap();
|
||||
assert_eq!(events.len(), 1);
|
||||
assert_eq!(events[0].event_type, "delete");
|
||||
let stale = OutboxEvent {
|
||||
generation: first,
|
||||
..events[0].clone()
|
||||
};
|
||||
assert!(!mark_success(&conn, &stale).unwrap());
|
||||
assert!(mark_success(&conn, &events[0]).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enqueue_participates_in_caller_transaction() {
|
||||
let conn = open_test_db();
|
||||
{
|
||||
let transaction = conn.unchecked_transaction().unwrap();
|
||||
enqueue(&transaction, "doc-tx", "delete", 11, "INBOX", None).unwrap();
|
||||
// Drop without commit: the message mutation and its search event must roll back together.
|
||||
}
|
||||
assert!(list_ready(&conn, 10).unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn event_claim_is_exclusive_until_lease_expires() {
|
||||
let conn = open_test_db();
|
||||
enqueue(&conn, "doc-claim", "delete", 3, "INBOX", None).unwrap();
|
||||
let event = list_ready(&conn, 1).unwrap().remove(0);
|
||||
assert!(claim(&conn, &event, 60).unwrap());
|
||||
assert!(!claim(&conn, &event, 60).unwrap());
|
||||
assert!(list_ready(&conn, 10).unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reenqueue_preserves_active_claim_lease() {
|
||||
let conn = open_test_db();
|
||||
enqueue(&conn, "doc-lease", "upsert", 4, "INBOX", Some("{}")).expect("initial enqueue");
|
||||
let event = list_ready(&conn, 1).unwrap().remove(0);
|
||||
assert!(claim(&conn, &event, 60).unwrap());
|
||||
|
||||
enqueue(&conn, "doc-lease", "delete", 4, "INBOX", None).expect("replacement enqueue");
|
||||
assert!(list_ready(&conn, 10).unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failures_backoff_and_dead_letter() {
|
||||
let conn = open_test_db();
|
||||
enqueue(&conn, "doc-2", "delete", 9, "Trash", None).unwrap();
|
||||
let event = list_ready(&conn, 1).unwrap().remove(0);
|
||||
assert!(mark_failure(&conn, &event, "boom", 1).unwrap());
|
||||
let stats = stats(&conn).unwrap();
|
||||
assert_eq!(stats.total, 1);
|
||||
assert_eq!(stats.dead_letters, 1);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user