diff --git a/crates/xmedia-bot/src/db.rs b/crates/xmedia-bot/src/db.rs index 1a42e06..da7826f 100644 --- a/crates/xmedia-bot/src/db.rs +++ b/crates/xmedia-bot/src/db.rs @@ -82,6 +82,26 @@ impl DbPool { pub fn path(&self) -> &str { &self.inner.path } + + /// [`with_conn`] for the many callers that answer a failed statement with + /// a default plus one log line: `what` names the operation and `level` + /// says how bad it is (`Error` when the failure loses work the caller + /// expected, `Warn` when the user is still served). + /// + /// [`with_conn`]: DbPool::with_conn + pub async fn with_conn_or(&self, level: log::Level, what: &str, default: T, f: F) -> T + where + T: Send + 'static, + F: FnOnce(&mut Connection) -> rusqlite::Result + Send + 'static, + { + match self.with_conn(f).await { + Ok(value) => value, + Err(e) => { + log::log!(level, "{what}: {e}"); + default + } + } + } } impl PoolInner { diff --git a/crates/xmedia-bot/src/link_cache.rs b/crates/xmedia-bot/src/link_cache.rs index d868271..e94e496 100644 --- a/crates/xmedia-bot/src/link_cache.rs +++ b/crates/xmedia-bot/src/link_cache.rs @@ -74,115 +74,110 @@ impl LinkCache { pub async fn get(&self, key: &str, ttl: Duration) -> Option { let key = key.to_string(); let ttl = ttl.as_secs_f64(); - let result = self - .pool - .with_conn(move |conn| { - let Some((payload, created_at)) = conn - .query_row( - "SELECT payload, created_at FROM link_cache WHERE url = ?1", - params![key], - |row| Ok((row.get::<_, String>(0)?, row.get::<_, f64>(1)?)), - ) - .optional()? - else { - return Ok(None); - }; - if now_f64() - created_at > ttl { - conn.execute("DELETE FROM link_cache WHERE url = ?1", params![key])?; - return Ok(None); - } - match serde_json::from_str::(&payload) { - Ok(post) => Ok(Some(post)), - Err(e) => { - // Unreadable payload (e.g. an older schema): drop it - // instead of re-failing the parse on every later hit. + self.pool + .with_conn_or( + log::Level::Warn, + "link cache read failed", + None, + move |conn| { + let Some((payload, created_at)) = conn + .query_row( + "SELECT payload, created_at FROM link_cache WHERE url = ?1", + params![key], + |row| Ok((row.get::<_, String>(0)?, row.get::<_, f64>(1)?)), + ) + .optional()? + else { + return Ok(None); + }; + if now_f64() - created_at > ttl { conn.execute("DELETE FROM link_cache WHERE url = ?1", params![key])?; - Err(rusqlite::Error::ToSqlConversionFailure(Box::new(e))) + return Ok(None); } - } - }) - .await; - match result { - Ok(v) => v, - Err(e) => { - log::warn!("link cache read failed: {e}"); - None - } - } + match serde_json::from_str::(&payload) { + Ok(post) => Ok(Some(post)), + Err(e) => { + // Unreadable payload (e.g. an older schema): drop it + // instead of re-failing the parse on every later hit. + conn.execute("DELETE FROM link_cache WHERE url = ?1", params![key])?; + Err(rusqlite::Error::ToSqlConversionFailure(Box::new(e))) + } + } + }, + ) + .await } pub async fn put(&self, key: &str, post: &CachedPost) { let key = key.to_string(); let payload = serde_json::to_string(post).expect("cached post serializes"); - let result = self - .pool - .with_conn(move |conn| { - conn.execute( - "INSERT OR REPLACE INTO link_cache (url, payload, created_at) VALUES (?1, ?2, ?3)", - params![key, payload, now_f64()], - )?; - Ok(()) - }) + self.pool + .with_conn_or( + log::Level::Warn, + "link cache write failed", + (), + move |conn| { + conn.execute( + "INSERT OR REPLACE INTO link_cache (url, payload, created_at) VALUES (?1, ?2, ?3)", + params![key, payload, now_f64()], + )?; + Ok(()) + }, + ) .await; - if let Err(e) = result { - log::warn!("link cache write failed: {e}"); - } } /// Drops an entry (e.g. a cached file id that turned out invalid). pub async fn remove(&self, key: &str) { let key = key.to_string(); - let result = self - .pool - .with_conn(move |conn| { - conn.execute("DELETE FROM link_cache WHERE url = ?1", params![key])?; - Ok(()) - }) + self.pool + .with_conn_or( + log::Level::Warn, + "link cache delete failed", + (), + move |conn| { + conn.execute("DELETE FROM link_cache WHERE url = ?1", params![key])?; + Ok(()) + }, + ) .await; - if let Err(e) = result { - log::warn!("link cache delete failed: {e}"); - } } /// Removes expired entries; returns how many were deleted. pub async fn prune(&self, ttl: Duration) -> usize { let cutoff = now_f64() - ttl.as_secs_f64(); - let result = self - .pool - .with_conn(move |conn| { - conn.execute( - "DELETE FROM link_cache WHERE created_at < ?1", - params![cutoff], - ) - }) - .await; - match result { - Ok(n) => n, - Err(e) => { - log::warn!("link cache prune failed: {e}"); - 0 - } - } + self.pool + .with_conn_or( + log::Level::Warn, + "link cache prune failed", + 0, + move |conn| { + conn.execute( + "DELETE FROM link_cache WHERE created_at < ?1", + params![cutoff], + ) + }, + ) + .await } /// Deletes one entry (by normalized cache key) or the whole cache when /// `key` is `None`. Returns how many rows were removed. pub async fn clear(&self, key: Option<&str>) -> usize { let key = key.map(str::to_string); - let result = self - .pool - .with_conn(move |conn| match &key { - Some(key) => conn.execute("DELETE FROM link_cache WHERE url = ?1", params![key]), - None => conn.execute("DELETE FROM link_cache", []), - }) - .await; - match result { - Ok(n) => n, - Err(e) => { - log::warn!("link cache clear failed: {e}"); - 0 - } - } + self.pool + .with_conn_or( + log::Level::Warn, + "link cache clear failed", + 0, + move |conn| match &key { + Some(key) => { + conn.execute("DELETE FROM link_cache WHERE url = ?1", params![key]) + } + None => conn.execute("DELETE FROM link_cache", []), + }, + ) + .await } } diff --git a/crates/xmedia-bot/src/queue.rs b/crates/xmedia-bot/src/queue.rs index 52ee8f7..3837f33 100644 --- a/crates/xmedia-bot/src/queue.rs +++ b/crates/xmedia-bot/src/queue.rs @@ -239,23 +239,20 @@ impl PersistentTaskQueue { /// `in_progress`). The startup repair reads these before the workers start: /// with no worker running, no row can be leased while it writes. pub async fn runnable_rows(&self) -> Vec<(String, String)> { - let result = self - .pool - .with_conn(|conn| { - let mut stmt = conn.prepare( - "SELECT id, payload FROM tasks WHERE status IN ('pending', 'in_progress') ORDER BY run_after", - )?; - let rows = stmt.query_map([], |row| Ok((row.get(0)?, row.get(1)?)))?; - rows.collect::>>() - }) - .await; - match result { - Ok(rows) => rows, - Err(e) => { - log::error!("queue row scan failed: {e}"); - Vec::new() - } - } + self.pool + .with_conn_or( + log::Level::Error, + "queue row scan failed", + Vec::new(), + |conn| { + let mut stmt = conn.prepare( + "SELECT id, payload FROM tasks WHERE status IN ('pending', 'in_progress') ORDER BY run_after", + )?; + let rows = stmt.query_map([], |row| Ok((row.get(0)?, row.get(1)?)))?; + rows.collect::>>() + }, + ) + .await } /// Replaces a runnable row's payload and restarts its attempt budget: the @@ -291,28 +288,25 @@ impl PersistentTaskQueue { } pub async fn pending_backlog(&self) -> Option<(i64, f64)> { - let result = self - .pool - .with_conn(|conn| { - let (count, oldest) = conn.query_row( - "SELECT COUNT(*), MIN(run_after) FROM tasks WHERE status='pending'", - [], - |row| Ok((row.get::<_, i64>(0)?, row.get::<_, Option>(1)?)), - )?; - // `MIN` over zero rows is NULL, so the count is what decides. - Ok(match oldest { - Some(oldest) if count > 0 => Some((count, oldest)), - _ => None, - }) - }) - .await; - match result { - Ok(v) => v, - Err(e) => { - log::error!("queue backlog query failed: {e}"); - None - } - } + self.pool + .with_conn_or( + log::Level::Error, + "queue backlog query failed", + None, + |conn| { + let (count, oldest) = conn.query_row( + "SELECT COUNT(*), MIN(run_after) FROM tasks WHERE status='pending'", + [], + |row| Ok((row.get::<_, i64>(0)?, row.get::<_, Option>(1)?)), + )?; + // `MIN` over zero rows is NULL, so the count is what decides. + Ok(match oldest { + Some(oldest) if count > 0 => Some((count, oldest)), + _ => None, + }) + }, + ) + .await } } @@ -455,23 +449,20 @@ impl QueueWorker { } async fn earliest_run_after(&self) -> Option { - let result = self - .pool - .with_conn(|conn| { - conn.query_row( - "SELECT MIN(run_after) FROM tasks WHERE status='pending'", - [], - |row| row.get::<_, Option>(0), - ) - }) - .await; - match result { - Ok(v) => v, - Err(e) => { - log::error!("queue timing query failed: {e}"); - None - } - } + self.pool + .with_conn_or( + log::Level::Error, + "queue timing query failed", + None, + |conn| { + conn.query_row( + "SELECT MIN(run_after) FROM tasks WHERE status='pending'", + [], + |row| row.get::<_, Option>(0), + ) + }, + ) + .await } /// Processes one leased row, keeping the lease alive while the handler diff --git a/crates/xmedia-bot/src/state.rs b/crates/xmedia-bot/src/state.rs index 2bec876..d9bc1d8 100644 --- a/crates/xmedia-bot/src/state.rs +++ b/crates/xmedia-bot/src/state.rs @@ -61,22 +61,24 @@ impl ChatStore { let chat_key = chat_id.to_string(); let payload = self .pool - .with_conn(move |conn| { - // Concurrent handler tasks (batch-forwards) may write chat_state - // while this read runs; the shared busy timeout handles the - // write-lock collision instead of failing the query. - conn.query_row( - "SELECT payload FROM chat_state WHERE chat_id = ?1", - params![chat_key], - |row| row.get::<_, String>(0), - ) - .optional() - }) + .with_conn_or( + log::Level::Warn, + "chat_state read failed", + None, + move |conn| { + // Concurrent handler tasks (batch-forwards) may write + // chat_state while this read runs; the shared busy timeout + // handles the write-lock collision instead of failing the + // query. + conn.query_row( + "SELECT payload FROM chat_state WHERE chat_id = ?1", + params![chat_key], + |row| row.get::<_, String>(0), + ) + .optional() + }, + ) .await - .unwrap_or_else(|e| { - log::warn!("chat_state read failed: {e}"); - None - }) .unwrap_or_default(); let data: ChatData = serde_json::from_str(&payload).unwrap_or_default(); self.cache.lock().insert(chat_id, data.clone()); @@ -88,19 +90,20 @@ impl ChatStore { self.cache.lock().insert(chat_id, data.clone()); let payload = serde_json::to_string(data).expect("chat state serializes"); let chat_id = chat_id.to_string(); - let result = self - .pool - .with_conn(move |conn| { - conn.execute( - "INSERT OR REPLACE INTO chat_state (chat_id, payload) VALUES (?1, ?2)", - params![chat_id, payload], - )?; - Ok(()) - }) + self.pool + .with_conn_or( + log::Level::Warn, + "chat_state write failed", + (), + move |conn| { + conn.execute( + "INSERT OR REPLACE INTO chat_state (chat_id, payload) VALUES (?1, ?2)", + params![chat_id, payload], + )?; + Ok(()) + }, + ) .await; - if let Err(e) = result { - log::warn!("chat_state write failed: {e}"); - } } /// The per-chat async lock serializing get→mutate→set cycles.