1 parent
83440f20e2
commit
12e0c99850
32 files changed
+729
-109
No files matched your search
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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,3 +1,4 @@
|
||||
pub mod blob_delete_outbox;
|
||||
pub mod calendar;
|
||||
pub mod contact_groups;
|
||||
pub mod contacts;
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user