From 925cff881d7efd1c825d5290652835be5662beae Mon Sep 17 00:00:00 2001 From: Anten Skrabec Date: Tue, 28 Jul 2026 21:51:50 -0600 Subject: [PATCH 1/2] fix: prevent duplicate pending operations in mod queue Adds partial unique indexes on `pending_operations` table to enforce one pending operation per (mod/addon, action) pair. This prevents duplicate queue entries when multiple users or requests attempt to queue the same operation concurrently. Changes: - Migration 019: Add unique indexes for mod and addon operations - insert_pending_op: Return friendly error message for duplicates - has_pending_url_op: Add dedup check for URL-based installs - URL install handler: Check for duplicates before download and handle constraint violations gracefully with user-facing messages Co-Authored-By: Claude Opus 4.6 (1M context) --- migrations/019_queue_unique_constraints.sql | 10 ++++ src/db/users.rs | 25 ++++++++-- src/web/handlers/mods.rs | 51 +++++++++++++++++---- 3 files changed, 75 insertions(+), 11 deletions(-) create mode 100644 migrations/019_queue_unique_constraints.sql diff --git a/migrations/019_queue_unique_constraints.sql b/migrations/019_queue_unique_constraints.sql new file mode 100644 index 0000000..5e37518 --- /dev/null +++ b/migrations/019_queue_unique_constraints.sql @@ -0,0 +1,10 @@ +-- Prevent duplicate pending operations in the queue +-- Partial unique indexes ensure one pending operation per (mod/addon, action) + +CREATE UNIQUE INDEX IF NOT EXISTS idx_pending_ops_mod_action + ON pending_operations(forge_mod_id, action) + WHERE item_type = 'mod' AND forge_mod_id IS NOT NULL; + +CREATE UNIQUE INDEX IF NOT EXISTS idx_pending_ops_addon_action + ON pending_operations(forge_addon_id, action) + WHERE item_type = 'addon' AND forge_addon_id IS NOT NULL; diff --git a/src/db/users.rs b/src/db/users.rs index 867a1a4..78ea17d 100644 --- a/src/db/users.rs +++ b/src/db/users.rs @@ -451,7 +451,7 @@ impl Database { // ── Pending Operations CRUD ─────────────────────────────────────── pub fn insert_pending_op(&self, op: &InsertPendingOp<'_>) -> rusqlite::Result { - self.conn.execute( + match self.conn.execute( "INSERT INTO pending_operations (action, forge_mod_id, forge_version_id, mod_name, metadata, queued_by, item_type, forge_addon_id, archive_path, source, source_url) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)", params![ @@ -467,8 +467,18 @@ impl Database { op.source, op.source_url, ], - )?; - Ok(self.conn.last_insert_rowid()) + ) { + Ok(_) => Ok(self.conn.last_insert_rowid()), + Err(rusqlite::Error::SqliteFailure(err, _)) + if err.extended_code == rusqlite::ffi::SQLITE_CONSTRAINT_UNIQUE => + { + Err(rusqlite::Error::SqliteFailure( + err, + Some("Operation already queued".to_string()), + )) + } + Err(e) => Err(e), + } } pub fn has_pending_op(&self, forge_mod_id: i64, action: QueueAction) -> rusqlite::Result { @@ -493,6 +503,15 @@ impl Database { Ok(count > 0) } + pub fn has_pending_url_op(&self, url: &str, action: QueueAction) -> rusqlite::Result { + let count: i64 = self.conn.query_row( + "SELECT COUNT(*) FROM pending_operations WHERE source_url = ?1 AND action = ?2", + params![url, action.as_str()], + |row| row.get(0), + )?; + Ok(count > 0) + } + pub fn list_pending_ops(&self) -> rusqlite::Result> { let mut stmt = self.conn.prepare( "SELECT id, action, forge_mod_id, forge_version_id, mod_name, metadata, queued_at, queued_by, item_type, forge_addon_id, archive_path, source, source_url diff --git a/src/web/handlers/mods.rs b/src/web/handlers/mods.rs index ff963e5..829bc30 100644 --- a/src/web/handlers/mods.rs +++ b/src/web/handlers/mods.rs @@ -1178,6 +1178,28 @@ async fn install_mod_from_url( // Queue if server running if should_queue_operation(state).await { + // Check for duplicate URL operation + let db_check = state.db.clone(); + let url_check = url.to_string(); + let already_queued = web::block(move || { + let db = db_check.lock(); + db.has_pending_url_op(&url_check, crate::db::users::QueueAction::Install) + }) + .await + .map_err(WebError::from)? + .map_err(WebError::from)?; + + if already_queued { + set_flash( + session, + "This URL is already queued for installation", + FlashType::Info, + ); + return Ok(HttpResponse::SeeOther() + .insert_header(("Location", "/quma/mods")) + .finish()); + } + let queue_dir = state.dirs.queue_dir(); let _ = std::fs::create_dir_all(&queue_dir); @@ -1201,7 +1223,7 @@ async fn install_mod_from_url( let mod_name_q = mod_name.clone(); let dest_str = dest.to_string_lossy().to_string(); let url_owned = url.to_string(); - let _ = web::block(move || { + match web::block(move || { let db = db.lock(); db.insert_pending_op(&crate::db::users::InsertPendingOp { action: crate::db::users::QueueAction::Install, @@ -1218,14 +1240,27 @@ async fn install_mod_from_url( }) }) .await - .map_err(WebError::from)? - .map_err(WebError::from)?; + { + Ok(Ok(_)) => { + set_flash( + session, + "Mod queued for install from URL", + FlashType::Success, + ); + } + Ok(Err(rusqlite::Error::SqliteFailure(err, msg))) + if err.extended_code == rusqlite::ffi::SQLITE_CONSTRAINT_UNIQUE => + { + set_flash( + session, + msg.as_deref().unwrap_or("Operation already queued"), + FlashType::Info, + ); + } + Ok(Err(e)) => return Err(WebError::from(e).into()), + Err(e) => return Err(WebError::from(e).into()), + } - set_flash( - session, - "Mod queued for install from URL", - FlashType::Success, - ); return Ok(HttpResponse::SeeOther() .insert_header(("Location", "/quma/mods")) .finish()); From 444ff78fa724dd2b9cff1cc78b5df1e863447391 Mon Sep 17 00:00:00 2001 From: Anten Skrabec Date: Tue, 28 Jul 2026 22:27:50 -0600 Subject: [PATCH 2/2] fix: add graceful constraint handling to mod/addon removal operations Applies the same SQLITE_CONSTRAINT_UNIQUE handling pattern used in URL installs to mod and addon removal queue operations. On the rare race condition where has_pending_op() passes but the unique index catches a duplicate, users now see a friendly "already queued" flash message instead of a raw 500 error. Additionally, add missing queueing support to remove_addon handler (previously it would always perform immediate removal even when the server was running and queue mode was enabled). Co-Authored-By: Claude Opus 4.6 (1M context) --- src/web/handlers/mods.rs | 69 +++++++++++++++++++++++++++++++++++----- 1 file changed, 61 insertions(+), 8 deletions(-) diff --git a/src/web/handlers/mods.rs b/src/web/handlers/mods.rs index 829bc30..ec9d45e 100644 --- a/src/web/handlers/mods.rs +++ b/src/web/handlers/mods.rs @@ -394,7 +394,7 @@ async fn try_queue_mod_op( // Remove doesn't need downloading let db = state.db.clone(); let mod_name_owned = mod_name.to_string(); - web::block(move || { + match web::block(move || { let db = db.lock(); db.insert_pending_op(&crate::db::users::InsertPendingOp { action: QueueAction::Remove, @@ -411,9 +411,22 @@ async fn try_queue_mod_op( }) }) .await - .map_err(WebError::from)? - .map_err(WebError::from)?; - set_flash(session, "Mod queued for removal", FlashType::Success); + { + Ok(Ok(_)) => { + set_flash(session, "Mod queued for removal", FlashType::Success); + } + Ok(Err(rusqlite::Error::SqliteFailure(err, _))) + if err.extended_code == rusqlite::ffi::SQLITE_CONSTRAINT_UNIQUE => + { + set_flash( + session, + "This mod removal is already queued", + FlashType::Info, + ); + } + Ok(Err(e)) => return Err(WebError::from(e)), + Err(e) => return Err(WebError::from(e)), + } } QueueAction::Install => { let version_id = version_id.ok_or(WebError::BadRequest("missing version_id".into()))?; @@ -525,7 +538,7 @@ async fn try_queue_addon_op( let db = state.db.clone(); let addon_name_owned = addon_name.to_string(); let username = user.username.clone(); - web::block(move || { + match web::block(move || { let db = db.lock(); db.insert_pending_op(&crate::db::users::InsertPendingOp { action: QueueAction::Remove, @@ -542,9 +555,22 @@ async fn try_queue_addon_op( }) }) .await - .map_err(WebError::from)? - .map_err(WebError::from)?; - set_flash(session, "Addon queued for removal", FlashType::Success); + { + Ok(Ok(_)) => { + set_flash(session, "Addon queued for removal", FlashType::Success); + } + Ok(Err(rusqlite::Error::SqliteFailure(err, _))) + if err.extended_code == rusqlite::ffi::SQLITE_CONSTRAINT_UNIQUE => + { + set_flash( + session, + "This addon removal is already queued", + FlashType::Info, + ); + } + Ok(Err(e)) => return Err(WebError::from(e)), + Err(e) => return Err(WebError::from(e)), + } } QueueAction::Install | QueueAction::Update => { let version_id = version_id.ok_or(WebError::BadRequest("missing version_id".into()))?; @@ -2968,6 +2994,33 @@ pub async fn remove_addon( }; let parent_mod_id = addon.parent_mod_id; + + // Check if the operation should be queued + let parent_forge_mod_id_opt = { + let db = state.db.lock(); + db.get_mod(parent_mod_id) + .ok() + .flatten() + .and_then(|m| m.forge_mod_id) + }; + if let Some(parent_forge_mod_id) = parent_forge_mod_id_opt { + if let Some(resp) = try_queue_addon_op( + &state, + &session, + &user, + QueueAction::Remove, + addon.forge_addon_id, + None, + &addon.name, + parent_forge_mod_id, + &format!("/quma/mods/{}#queue", parent_mod_id), + ) + .await? + { + return Ok(resp); + } + } + let dirs = Arc::clone(&state.dirs); let config = state.config_cloned();