mirror of
https://github.com/TheFunny/TelegramTwitterMediaBot.git
synced 2026-09-23 23:32:05 +00:00
state: serialize per-chat get→mutate→set with ChatStore::update
Concurrent handler tasks (the batch-forward design spawns several per chat) each snapshotted the same ChatData and last-writer-wins silently dropped mutations — e.g. a second edit_message record, leaving one prompt's Forward button dead. All write cycles now run under a per-chat async lock; read-only callers keep get().
This commit is contained in:
@@ -238,9 +238,11 @@ async fn execute_command(
|
|||||||
Command::SetForwardChannel(channel) => {
|
Command::SetForwardChannel(channel) => {
|
||||||
let result = match set_forward_channel_handler(bot, message, channel).await {
|
let result = match set_forward_channel_handler(bot, message, channel).await {
|
||||||
Ok(channel_id) => {
|
Ok(channel_id) => {
|
||||||
let mut chat_data = CHAT_STORE.get(message.chat.id.0).await;
|
CHAT_STORE
|
||||||
chat_data.forward_channel_id = Some(channel_id);
|
.update(message.chat.id.0, |data| {
|
||||||
CHAT_STORE.set(message.chat.id.0, &chat_data).await;
|
data.forward_channel_id = Some(channel_id);
|
||||||
|
})
|
||||||
|
.await;
|
||||||
"Add successfully.".to_string()
|
"Add successfully.".to_string()
|
||||||
}
|
}
|
||||||
Err(SetForwardChannelError::EmptyParameter) => {
|
Err(SetForwardChannelError::EmptyParameter) => {
|
||||||
@@ -264,31 +266,34 @@ async fn execute_command(
|
|||||||
}
|
}
|
||||||
Command::RemoveForwardChannel => {
|
Command::RemoveForwardChannel => {
|
||||||
let chat_id = message.chat.id.0;
|
let chat_id = message.chat.id.0;
|
||||||
let mut chat_data = CHAT_STORE.get(chat_id).await;
|
let text = CHAT_STORE
|
||||||
let text = if chat_data.forward_channel_id.is_some() {
|
.update(chat_id, |data| {
|
||||||
chat_data.forward_channel_id = None;
|
if data.forward_channel_id.is_some() {
|
||||||
CHAT_STORE.set(chat_id, &chat_data).await;
|
data.forward_channel_id = None;
|
||||||
"Remove successfully.".to_string()
|
"Remove successfully.".to_string()
|
||||||
} else {
|
} else {
|
||||||
"No channel to remove.".to_string()
|
"No channel to remove.".to_string()
|
||||||
};
|
}
|
||||||
|
})
|
||||||
|
.await;
|
||||||
reply(bot.clone(), message.clone(), text).await?;
|
reply(bot.clone(), message.clone(), text).await?;
|
||||||
}
|
}
|
||||||
Command::EditBeforeForward => {
|
Command::EditBeforeForward => {
|
||||||
let chat_id = message.chat.id.0;
|
let chat_id = message.chat.id.0;
|
||||||
let mut chat_data = CHAT_STORE.get(chat_id).await;
|
let text = CHAT_STORE
|
||||||
let text = if chat_data.forward_channel_id.is_none() {
|
.update(chat_id, |data| {
|
||||||
|
if data.forward_channel_id.is_none() {
|
||||||
"Please enable forward channel first.".to_string()
|
"Please enable forward channel first.".to_string()
|
||||||
} else if chat_data.edit_before_forward {
|
} else if data.edit_before_forward {
|
||||||
chat_data.edit_before_forward = false;
|
data.edit_before_forward = false;
|
||||||
chat_data.edit_message.clear();
|
data.edit_message.clear();
|
||||||
CHAT_STORE.set(chat_id, &chat_data).await;
|
|
||||||
"Disable edit before forward.".to_string()
|
"Disable edit before forward.".to_string()
|
||||||
} else {
|
} else {
|
||||||
chat_data.edit_before_forward = true;
|
data.edit_before_forward = true;
|
||||||
CHAT_STORE.set(chat_id, &chat_data).await;
|
|
||||||
"Enable edit before forward.".to_string()
|
"Enable edit before forward.".to_string()
|
||||||
};
|
}
|
||||||
|
})
|
||||||
|
.await;
|
||||||
reply(bot.clone(), message.clone(), text).await?;
|
reply(bot.clone(), message.clone(), text).await?;
|
||||||
}
|
}
|
||||||
Command::SetTemplate(name) => {
|
Command::SetTemplate(name) => {
|
||||||
@@ -302,11 +307,14 @@ async fn execute_command(
|
|||||||
} else if name.is_empty() {
|
} else if name.is_empty() {
|
||||||
"Please provide a name for the template.".to_string()
|
"Please provide a name for the template.".to_string()
|
||||||
} else {
|
} else {
|
||||||
let mut chat_data = CHAT_STORE.get(chat_id).await;
|
CHAT_STORE
|
||||||
chat_data
|
.update(chat_id, |data| {
|
||||||
.template
|
data.template.insert(
|
||||||
.insert(name, html_escape::encode_text(reply_text).into_owned());
|
name,
|
||||||
CHAT_STORE.set(chat_id, &chat_data).await;
|
html_escape::encode_text(reply_text).into_owned(),
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.await;
|
||||||
"Template set.".to_string()
|
"Template set.".to_string()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -344,9 +352,11 @@ async fn execute_command(
|
|||||||
.await?;
|
.await?;
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
let mut chat_data = CHAT_STORE.get(chat_id).await;
|
CHAT_STORE
|
||||||
chat_data.message_format.insert(site.to_string(), format);
|
.update(chat_id, |data| {
|
||||||
CHAT_STORE.set(chat_id, &chat_data).await;
|
data.message_format.insert(site.to_string(), format);
|
||||||
|
})
|
||||||
|
.await;
|
||||||
reply(bot.clone(), message.clone(), "Format set.").await?;
|
reply(bot.clone(), message.clone(), "Format set.").await?;
|
||||||
}
|
}
|
||||||
Command::ClearCache(arg) => {
|
Command::ClearCache(arg) => {
|
||||||
@@ -793,7 +803,7 @@ pub async fn callback_query_handler(bot: Bot, query: CallbackQuery) -> Result<()
|
|||||||
let chat_id = message.chat().id.0;
|
let chat_id = message.chat().id.0;
|
||||||
let prompt_message_id = message.id().0 as i64;
|
let prompt_message_id = message.id().0 as i64;
|
||||||
let ttl_secs = CONFIG.edit_message_ttl.as_secs() as i64;
|
let ttl_secs = CONFIG.edit_message_ttl.as_secs() as i64;
|
||||||
let mut chat_data = CHAT_STORE.get(chat_id).await;
|
let chat_data = CHAT_STORE.get(chat_id).await;
|
||||||
let edit = chat_data.edit_message.get(&prompt_message_id).cloned();
|
let edit = chat_data.edit_message.get(&prompt_message_id).cloned();
|
||||||
let Some(edit) = edit else {
|
let Some(edit) = edit else {
|
||||||
log::info!(
|
log::info!(
|
||||||
@@ -807,8 +817,11 @@ pub async fn callback_query_handler(bot: Bot, query: CallbackQuery) -> Result<()
|
|||||||
};
|
};
|
||||||
// Lazy expiry: a stale record (past the TTL, not yet swept) is dropped.
|
// Lazy expiry: a stale record (past the TTL, not yet swept) is dropped.
|
||||||
if edit.created_at + ttl_secs <= unix_now() {
|
if edit.created_at + ttl_secs <= unix_now() {
|
||||||
chat_data.edit_message.remove(&prompt_message_id);
|
CHAT_STORE
|
||||||
CHAT_STORE.set(chat_id, &chat_data).await;
|
.update(chat_id, |data| {
|
||||||
|
data.edit_message.remove(&prompt_message_id);
|
||||||
|
})
|
||||||
|
.await;
|
||||||
bot.answer_callback_query(callback_query_id)
|
bot.answer_callback_query(callback_query_id)
|
||||||
.text("Expired")
|
.text("Expired")
|
||||||
.await?;
|
.await?;
|
||||||
@@ -844,8 +857,11 @@ pub async fn callback_query_handler(bot: Bot, query: CallbackQuery) -> Result<()
|
|||||||
let _ = bot
|
let _ = bot
|
||||||
.delete_message(ChatId(chat_id), MessageId(prompt_message_id as i32))
|
.delete_message(ChatId(chat_id), MessageId(prompt_message_id as i32))
|
||||||
.await;
|
.await;
|
||||||
chat_data.edit_message.remove(&prompt_message_id);
|
CHAT_STORE
|
||||||
CHAT_STORE.set(chat_id, &chat_data).await;
|
.update(chat_id, |data| {
|
||||||
|
data.edit_message.remove(&prompt_message_id);
|
||||||
|
})
|
||||||
|
.await;
|
||||||
}
|
}
|
||||||
Err(send::SendError::Retryable {
|
Err(send::SendError::Retryable {
|
||||||
delay_seconds,
|
delay_seconds,
|
||||||
@@ -884,10 +900,13 @@ pub async fn callback_query_handler(bot: Bot, query: CallbackQuery) -> Result<()
|
|||||||
.caption(template_html)
|
.caption(template_html)
|
||||||
.parse_mode(ParseMode::Html)
|
.parse_mode(ParseMode::Html)
|
||||||
.await;
|
.await;
|
||||||
if let Some(entry) = chat_data.edit_message.get_mut(&prompt_message_id) {
|
CHAT_STORE
|
||||||
|
.update(chat_id, |data| {
|
||||||
|
if let Some(entry) = data.edit_message.get_mut(&prompt_message_id) {
|
||||||
entry.template = name.to_string();
|
entry.template = name.to_string();
|
||||||
}
|
}
|
||||||
CHAT_STORE.set(chat_id, &chat_data).await;
|
})
|
||||||
|
.await;
|
||||||
log::info!("template '{name}' applied to prompt {prompt_message_id}");
|
log::info!("template '{name}' applied to prompt {prompt_message_id}");
|
||||||
}
|
}
|
||||||
bot.answer_callback_query(callback_query_id).await?;
|
bot.answer_callback_query(callback_query_id).await?;
|
||||||
|
|||||||
@@ -1048,8 +1048,7 @@ pub async fn post_send_actions(bot: &Bot, task: &Task, message_ids: Vec<i64>) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if edit_before_forward {
|
if edit_before_forward {
|
||||||
let mut chat_data = CHAT_STORE.get(chat_id).await;
|
let keyboard = build_edit_markup(&CHAT_STORE.get(chat_id).await.template);
|
||||||
let keyboard = build_edit_markup(&chat_data.template);
|
|
||||||
match bot
|
match bot
|
||||||
.send_message(ChatId(chat_id), "Reply to edit message.")
|
.send_message(ChatId(chat_id), "Reply to edit message.")
|
||||||
.reply_markup(keyboard)
|
.reply_markup(keyboard)
|
||||||
@@ -1064,8 +1063,12 @@ pub async fn post_send_actions(bot: &Bot, task: &Task, message_ids: Vec<i64>) {
|
|||||||
prompt.id.0,
|
prompt.id.0,
|
||||||
message_ids.len()
|
message_ids.len()
|
||||||
);
|
);
|
||||||
chat_data.edit_message.insert(
|
let prompt_id = prompt.id.0 as i64;
|
||||||
prompt.id.0 as i64,
|
let source_url = source_url.clone();
|
||||||
|
CHAT_STORE
|
||||||
|
.update(chat_id, move |data| {
|
||||||
|
data.edit_message.insert(
|
||||||
|
prompt_id,
|
||||||
EditMessage {
|
EditMessage {
|
||||||
url: source_url,
|
url: source_url,
|
||||||
chat_id,
|
chat_id,
|
||||||
@@ -1074,7 +1077,8 @@ pub async fn post_send_actions(bot: &Bot, task: &Task, message_ids: Vec<i64>) {
|
|||||||
created_at: unix_now(),
|
created_at: unix_now(),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
CHAT_STORE.set(chat_id, &chat_data).await;
|
})
|
||||||
|
.await;
|
||||||
}
|
}
|
||||||
Err(e) => log::error!("failed to send edit prompt: {e}"),
|
Err(e) => log::error!("failed to send edit prompt: {e}"),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ use rusqlite::params;
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
use std::sync::Arc;
|
||||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Default, Clone, Debug)]
|
#[derive(Serialize, Deserialize, Default, Clone, Debug)]
|
||||||
@@ -34,6 +35,9 @@ pub struct EditMessage {
|
|||||||
pub struct ChatStore {
|
pub struct ChatStore {
|
||||||
/// In-memory cache; the DB is the source of truth on first access.
|
/// In-memory cache; the DB is the source of truth on first access.
|
||||||
cache: Mutex<HashMap<i64, ChatData>>,
|
cache: Mutex<HashMap<i64, ChatData>>,
|
||||||
|
/// Per-chat async locks serializing get→mutate→set so concurrent handler
|
||||||
|
/// tasks (batch-forwards, callbacks) cannot clobber each other's writes.
|
||||||
|
locks: Mutex<HashMap<i64, Arc<tokio::sync::Mutex<()>>>>,
|
||||||
db_path: String,
|
db_path: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,6 +66,7 @@ impl ChatStore {
|
|||||||
drop(conn);
|
drop(conn);
|
||||||
Ok(ChatStore {
|
Ok(ChatStore {
|
||||||
cache: Mutex::new(HashMap::new()),
|
cache: Mutex::new(HashMap::new()),
|
||||||
|
locks: Mutex::new(HashMap::new()),
|
||||||
db_path: path.to_string(),
|
db_path: path.to_string(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -111,6 +116,26 @@ impl ChatStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Serializes a get→mutate→set cycle per chat: concurrent handler tasks
|
||||||
|
/// (the batch-forward design spawns several per chat) each snapshot the
|
||||||
|
/// same `ChatData` and last-writer-wins would silently drop mutations,
|
||||||
|
/// e.g. a second `edit_message` record. The per-chat lock makes the
|
||||||
|
/// cycle atomic. Returns the closure's result.
|
||||||
|
pub async fn update<R>(&self, chat_id: i64, f: impl FnOnce(&mut ChatData) -> R) -> R {
|
||||||
|
let lock = {
|
||||||
|
let mut locks = self.locks.lock();
|
||||||
|
locks
|
||||||
|
.entry(chat_id)
|
||||||
|
.or_insert_with(|| Arc::new(tokio::sync::Mutex::new(())))
|
||||||
|
.clone()
|
||||||
|
};
|
||||||
|
let _guard = lock.lock().await;
|
||||||
|
let mut data = self.get(chat_id).await;
|
||||||
|
let r = f(&mut data);
|
||||||
|
self.set(chat_id, &data).await;
|
||||||
|
r
|
||||||
|
}
|
||||||
|
|
||||||
/// Removes edit-before-forward records whose `created_at + ttl` is in the
|
/// Removes edit-before-forward records whose `created_at + ttl` is in the
|
||||||
/// past. Returns the removed `(chat_id, prompt_message_id)` pairs so the
|
/// past. Returns the removed `(chat_id, prompt_message_id)` pairs so the
|
||||||
/// caller can clear the prompt's buttons.
|
/// caller can clear the prompt's buttons.
|
||||||
@@ -152,3 +177,45 @@ impl ChatStore {
|
|||||||
removed
|
removed
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn concurrent_updates_do_not_lose_edit_records() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let store = std::sync::Arc::new(
|
||||||
|
ChatStore::open(dir.path().join("s.db").to_str().unwrap()).unwrap(),
|
||||||
|
);
|
||||||
|
let mut handles = Vec::new();
|
||||||
|
for i in 0..4 {
|
||||||
|
let store = Arc::clone(&store);
|
||||||
|
handles.push(tokio::spawn(async move {
|
||||||
|
store
|
||||||
|
.update(1001, |data| {
|
||||||
|
data.edit_message.insert(
|
||||||
|
i,
|
||||||
|
EditMessage {
|
||||||
|
url: format!("https://x.com/u/status/{i}"),
|
||||||
|
chat_id: 1001,
|
||||||
|
forward_message_ids: vec![i],
|
||||||
|
template: String::new(),
|
||||||
|
created_at: 0,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
for h in handles {
|
||||||
|
h.await.unwrap();
|
||||||
|
}
|
||||||
|
let data = store.get(1001).await;
|
||||||
|
assert_eq!(
|
||||||
|
data.edit_message.len(),
|
||||||
|
4,
|
||||||
|
"concurrent get→mutate→set must not drop records"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user