This commit is contained in:
Mateusz Gruszczyński committed 2026-10-31 14:36:07 +01:00
1 parent fc81ecafd6
commit dc57aa7089
89 files changed
+5290 -1551

No files matched your search

+1 -1
View File
@@ -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
View File
@@ -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();
+1 -1
View File
@@ -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
View File
@@ -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();
+1
View File
@@ -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
View File
@@ -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 {
+258
View File
@@ -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);
}
}