state: evict idle chats from the ChatStore cache

prune_expired only shrank edit_message maps, so the cache kept one
ChatData per chat forever (a leak proportional to chat count). Chats
without live edit records are now dropped from the cache and their
per-chat lock (DB row persists; get() reloads). Lock order kept safe:
prune never holds the cache lock while taking the per-chat locks.
This commit is contained in:
2026-08-08 20:16:26 +08:00
parent 16ed53fead
commit ee6f3e4a27
+20
View File
@@ -143,6 +143,10 @@ impl ChatStore {
let now = unix_now(); let now = unix_now();
let ttl_secs = ttl.as_secs() as i64; let ttl_secs = ttl.as_secs() as i64;
let mut removed = Vec::new(); let mut removed = Vec::new();
// Chats with no live edit records: evicted from the cache (and their
// per-chat lock) so the cache stays bounded to active prompts. The DB
// keeps the row; the next get() reloads it.
let mut evicted_chats = Vec::new();
let changed: Vec<(i64, ChatData)> = { let changed: Vec<(i64, ChatData)> = {
let mut cache = self.cache.lock(); let mut cache = self.cache.lock();
let mut out = Vec::new(); let mut out = Vec::new();
@@ -159,15 +163,31 @@ impl ChatStore {
} }
} }
if kept.len() != data.edit_message.len() { if kept.len() != data.edit_message.len() {
// Persist the pruned row (removes expired records from
// the DB too, not just the cache).
data.edit_message = kept; data.edit_message = kept;
out.push((*chat_id, data.clone())); out.push((*chat_id, data.clone()));
} }
if data.edit_message.is_empty() {
evicted_chats.push(*chat_id);
} }
}
// Lock order: update() takes the per-chat lock before the cache
// lock, so prune must not hold the cache lock while taking locks.
drop(cache);
out out
}; };
for (chat_id, data) in changed { for (chat_id, data) in changed {
self.set(chat_id, &data).await; self.set(chat_id, &data).await;
} }
if !evicted_chats.is_empty() {
let mut cache = self.cache.lock();
let mut locks = self.locks.lock();
for chat_id in &evicted_chats {
cache.remove(chat_id);
locks.remove(chat_id);
}
}
if !removed.is_empty() { if !removed.is_empty() {
log::info!( log::info!(
"pruned {} expired edit-before-forward record(s)", "pruned {} expired edit-before-forward record(s)",