fix(approval): clean_expired 按 code_hash 定位删除,修复并发 index 错位

P0-5: 原实现快照 (uid, index, code_hash) 后释放锁更新 DB,再重新加锁
      按 index remove。若两阶段间 handle_reply 增删了条目,index 指向
      错误条目,可能把未过期的审批误标 Expired 并通知等待方。

      改为两阶段都按 code_hash 精确定位:收集过期 hash → 更新 DB →
      重新加锁按 hash 查找删除,并发回复只会让查找安全跳过。
This commit is contained in:
2026-06-22 10:48:16 +08:00
parent 4f80e81b0b
commit 1e33f527e0
+22 -10
View File
@@ -192,22 +192,32 @@ impl ApprovalManager {
} }
/// 清理过期审批(按时间判定,通知等待方 + 更新 DB) /// 清理过期审批(按时间判定,通知等待方 + 更新 DB)
///
/// 两阶段加锁:收集过期 code_hash → 更新 DB → 重新加锁按 code_hash 定位删除。
/// 不依赖快照 index,避免并发回复导致 index 错位误删。
pub async fn clean_expired(&self) { pub async fn clean_expired(&self) {
let now = Instant::now(); let now = Instant::now();
let mut expired = Vec::new();
// 第一阶段:持锁收集过期条目的 code_hash
let mut expired_hashes: Vec<String> = Vec::new();
{ {
let map = self.pending.lock().await; let map = self.pending.lock().await;
for (uid, entries) in map.iter() { for entries in map.values() {
for (i, e) in entries.iter().enumerate() { for e in entries {
if now > e.expires_at { if now > e.expires_at {
expired.push((uid.clone(), i, e.code_hash.clone())); expired_hashes.push(e.code_hash.clone());
} }
} }
} }
} }
if expired_hashes.is_empty() {
return;
}
// DB 更新(按 code_hash 精确匹配,无错位问题)
if let Some(ref pool) = self.pool { if let Some(ref pool) = self.pool {
for (_uid, _i, code_hash) in &expired { for code_hash in &expired_hashes {
let _ = sqlx::query( let _ = sqlx::query(
"UPDATE pending_approvals SET status = 'expired' WHERE code_hash = $1 AND status = 'pending'", "UPDATE pending_approvals SET status = 'expired' WHERE code_hash = $1 AND status = 'pending'",
) )
@@ -217,15 +227,17 @@ impl ApprovalManager {
} }
} }
// 第二阶段:重新持锁,按 code_hash 定位并移除
// (即使两阶段间 handle_reply 增删了条目,按 hash 查找也能安全跳过)
let mut map = self.pending.lock().await; let mut map = self.pending.lock().await;
for (uid, i, _) in expired.iter().rev() { for code_hash in &expired_hashes {
if let Some(entries) = map.get_mut(uid) for entries in map.values_mut() {
&& *i < entries.len() if let Some(idx) = entries.iter().position(|e| &e.code_hash == code_hash) {
{ let entry = entries.remove(idx);
let entry = entries.remove(*i);
let _ = entry.sender.send(ApprovalDecision::Expired); let _ = entry.sender.send(ApprovalDecision::Expired);
} }
} }
}
map.retain(|_, v| !v.is_empty()); map.retain(|_, v| !v.is_empty());
} }
} }