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

This commit is contained in:
Mateusz Gruszczyński committed 2026-11-02 14:23:13 +01:00
1 parent 83440f20e2
commit 12e0c99850
32 files changed
+729 -109

No files matched your search

+155
View File
@@ -0,0 +1,155 @@
use std::time::{SystemTime, UNIX_EPOCH};
use crate::db::pool::{Connection, params};
#[derive(Debug, Clone)]
pub struct BlobDeleteEvent {
pub id: String,
pub storage_key: String,
pub attempts: u32,
}
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, storage_key: &str) -> Result<BlobDeleteEvent, String> {
if storage_key.trim().is_empty() {
return Err("cannot enqueue an empty blob storage key".to_string());
}
let id = 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_blob_delete_outbox::Q_001),
params![&id, storage_key, now, now],
),
_ => conn.execute(
crate::dbq!(db_blob_delete_outbox::Q_001),
params![&id, storage_key, now],
),
};
result.map_err(|e| format!("failed to enqueue blob deletion: {e}"))?;
Ok(BlobDeleteEvent {
id,
storage_key: storage_key.to_string(),
attempts: 0,
})
}
pub fn list_ready(conn: &Connection, limit: usize) -> Result<Vec<BlobDeleteEvent>, String> {
let now = now_epoch();
let mut stmt = conn
.prepare(crate::dbq!(db_blob_delete_outbox::Q_002))
.map_err(|e| format!("failed to prepare blob deletion outbox query: {e}"))?;
let query_params = match crate::db::pool::configured_backend() {
crate::db::pool::DatabaseBackend::Mysql => params![now, now, limit],
_ => params![now, limit],
};
let rows = stmt
.query_map(query_params, |row| {
Ok(BlobDeleteEvent {
id: row.get(0)?,
storage_key: row.get(1)?,
attempts: row.get::<_, i64>(2)?.max(0) as u32,
})
})
.map_err(|e| format!("failed to query blob deletion outbox: {e}"))?;
rows.map(|row| row.map_err(|e| format!("failed to read blob deletion outbox row: {e}")))
.collect()
}
pub fn claim(conn: &Connection, event: &BlobDeleteEvent, 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_blob_delete_outbox::Q_003),
params![lease_until, &event.id, now, now],
),
_ => conn.execute(
crate::dbq!(db_blob_delete_outbox::Q_003),
params![&event.id, lease_until, now],
),
}
.map_err(|e| format!("failed to claim blob deletion: {e}"))?;
Ok(changed > 0)
}
pub fn mark_success(conn: &Connection, event: &BlobDeleteEvent) -> Result<bool, String> {
let changed = conn
.execute(
crate::dbq!(db_blob_delete_outbox::Q_004),
params![&event.id],
)
.map_err(|e| format!("failed to acknowledge blob deletion: {e}"))?;
Ok(changed > 0)
}
pub fn mark_failure(
conn: &Connection,
event: &BlobDeleteEvent,
error: &str,
) -> Result<bool, String> {
let attempts = event.attempts.saturating_add(1);
let backoff = 2u64.saturating_pow(attempts.min(12)).min(3600);
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_blob_delete_outbox::Q_005),
params![attempts, available_at, &truncated, now, &event.id],
),
_ => conn.execute(
crate::dbq!(db_blob_delete_outbox::Q_005),
params![&event.id, attempts, available_at, &truncated, now],
),
}
.map_err(|e| format!("failed to update blob deletion retry state: {e}"))?;
Ok(changed > 0)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::db::pool::open_test_db;
#[test]
fn enqueue_participates_in_caller_transaction() {
let conn = open_test_db();
{
let transaction = conn.unchecked_transaction().unwrap();
enqueue(&transaction, "users/test/drafts/d/attachments/a").unwrap();
}
assert!(list_ready(&conn, 10).unwrap().is_empty());
}
#[test]
fn failed_deletions_are_retried_instead_of_dead_lettered() {
let conn = open_test_db();
let event = enqueue(&conn, "users/test/drafts/d/attachments/a").unwrap();
assert!(claim(&conn, &event, 60).unwrap());
assert!(mark_failure(&conn, &event, "storage unavailable").unwrap());
let count: i64 = conn
.query_row(
"SELECT COUNT(*) FROM blob_delete_outbox WHERE id=?1",
params![&event.id],
|row| row.get(0),
)
.unwrap();
assert_eq!(count, 1);
}
#[test]
fn successful_delete_is_acknowledged() {
let conn = open_test_db();
let event = enqueue(&conn, "users/test/drafts/d/attachments/a").unwrap();
assert!(mark_success(&conn, &event).unwrap());
assert!(list_ready(&conn, 10).unwrap().is_empty());
}
}
+61
View File
@@ -257,6 +257,67 @@ pub fn get_draft_attachments(
Ok(attachments)
}
pub fn lock_draft_for_update(conn: &Connection, id: &str) -> Result<bool, String> {
match conn.query_row(crate::dbq!(db_drafts::Q_010), params![id], |_row| Ok(())) {
Ok(()) => Ok(true),
Err(crate::db::pool::DbError::QueryReturnedNoRows) => Ok(false),
Err(e) => Err(format!("Failed to lock draft: {e}")),
}
}
pub fn get_draft_attachment_for_update(
conn: &Connection,
draft_id: &str,
attachment_id: &str,
) -> Result<Option<DraftAttachment>, String> {
let result = conn.query_row(
crate::dbq!(db_drafts::Q_011),
params![draft_id, attachment_id],
|row| {
Ok(DraftAttachment {
id: row.get(0)?,
draft_id: row.get(1)?,
filename: row.get(2)?,
content_type: row.get(3)?,
size: row.get(4)?,
storage_key: row.get(5)?,
checksum_sha256: row.get(6)?,
created_at: row.get(7)?,
})
},
);
match result {
Ok(attachment) => Ok(Some(attachment)),
Err(crate::db::pool::DbError::QueryReturnedNoRows) => Ok(None),
Err(e) => Err(format!("Failed to lock draft attachment: {e}")),
}
}
pub fn get_draft_attachments_for_update(
conn: &Connection,
draft_id: &str,
) -> Result<Vec<DraftAttachment>, String> {
let mut stmt = conn
.prepare(crate::dbq!(db_drafts::Q_012))
.map_err(|e| format!("Failed to prepare locked draft attachments query: {e}"))?;
let rows = stmt
.query_map(params![draft_id], |row| {
Ok(DraftAttachment {
id: row.get(0)?,
draft_id: row.get(1)?,
filename: row.get(2)?,
content_type: row.get(3)?,
size: row.get(4)?,
storage_key: row.get(5)?,
checksum_sha256: row.get(6)?,
created_at: row.get(7)?,
})
})
.map_err(|e| format!("Failed to query locked draft attachments: {e}"))?;
rows.map(|row| row.map_err(|e| format!("Failed to read locked attachment row: {e}")))
.collect()
}
pub fn delete_draft_attachment(conn: &Connection, id: &str) -> Result<bool, String> {
let deleted = conn
.execute(crate::dbq!(db_drafts::Q_008), params![id])
+1
View File
@@ -1,3 +1,4 @@
pub mod blob_delete_outbox;
pub mod calendar;
pub mod contact_groups;
pub mod contacts;
+14
View File
@@ -30,6 +30,10 @@ const SQLITE_MIGRATIONS: &[(u32, &str)] = &[
48,
include_str!("../../migrations/sqlite/V048__folder_counter_generation.sql"),
),
(
49,
include_str!("../../migrations/sqlite/V049__blob_delete_outbox.sql"),
),
];
const MYSQL_MIGRATIONS: &[(u32, &str)] = &[
(
@@ -40,6 +44,10 @@ const MYSQL_MIGRATIONS: &[(u32, &str)] = &[
48,
include_str!("../../migrations/mysql/V048__folder_counter_generation.sql"),
),
(
49,
include_str!("../../migrations/mysql/V049__blob_delete_outbox.sql"),
),
];
const PGSQL_MIGRATIONS: &[(u32, &str)] = &[
(
@@ -50,9 +58,14 @@ const PGSQL_MIGRATIONS: &[(u32, &str)] = &[
48,
include_str!("../../migrations/pgsql/V048__folder_counter_generation.sql"),
),
(
49,
include_str!("../../migrations/pgsql/V049__blob_delete_outbox.sql"),
),
];
const TENANT_TABLES: &[&str] = &[
"blob_delete_outbox",
"calendar_events",
"calendar_settings",
"contact_group_members",
@@ -83,6 +96,7 @@ const TENANT_TABLES: &[&str] = &[
];
const IMPORT_TABLES: &[&str] = &[
"blob_delete_outbox",
"calendar_events",
"calendar_settings",
"contact_groups",