Skip to content

Commit 7dfe373

Browse files
committed
fix(session): 批量删除改 rusqlite transaction 防悬挂事务
批4 审查 P3-3:delete_sessions_batch 手写 BEGIN/COMMIT 在 COMMIT 失败 路径(如延迟外键违约——SQLite 保留打开事务)不 ROLLBACK,悬挂事务吞掉 同连接后续语句。改 transaction()(Drop 自动回滚,与 db_notes/db_fragments 同手法),补回归测试:defer_foreign_keys + 无级联 FK 守卫表精确模拟 COMMIT 期失败,断言整体回滚且连接无悬挂(后续批量照常)。 批4 审查 P3-4:批量删除上限原复制 200 与列表单页上限双源——收口为引用 commands_session::LIST_LIMIT_MAX(pub(crate) 单一来源);运行中会话口径 与单条 delete_session pre-existing 同口径,注释说明不加新行为。
1 parent 23b0eeb commit 7dfe373

4 files changed

Lines changed: 79 additions & 31 deletions

File tree

‎app/src-tauri/src/commands_session.rs‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,11 @@ use crate::commands::{normalize_title, AppState, TITLE_MAX_CHARS};
1313
use crate::types::{NewSession, NewSessionOcrBlock, NewSessionSegment, Session, SessionDetail, SessionListItem};
1414

1515
/// 会话列表单页上限。
16-
const LIST_LIMIT_MAX: u64 = 200;
16+
///
17+
/// @ai-context: pub(crate) 供 commands_session_delete 等引用(批 4 审查修复
18+
/// P3-4:批量删除上限曾复制为独立常量 200——双源漂移风险,
19+
/// 列表页单页量=勾选集天然上界,单一来源收口于此)。
20+
pub(crate) const LIST_LIMIT_MAX: u64 = 200;
1721

1822
/// 会话列表项填充显示序号(v0.11.5):按 (started_at, id) 升序 rank 一次赋值。
1923
///

‎app/src-tauri/src/commands_session_delete.rs‎

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,14 +13,19 @@
1313
use tauri::State;
1414

1515
use crate::commands::AppState;
16+
use crate::commands_session::LIST_LIMIT_MAX;
1617
use crate::types::BatchSessionDeleteResult;
1718

18-
/// 批量删除单次上限(= 列表单页上限,见 commands_session::LIST_LIMIT_MAX)。
19+
/// 批量删除单次上限(单一来源 = 列表单页上限 LIST_LIMIT_MAX——审查修复 P3-4:
20+
/// 曾在本文件复制 200 与 commands_session 双源,防漂移收口引用)。
1921
///
20-
/// @ai-context Why 有界:勾选集恒为当前可见列表子集(≤200),上限只是防御
21-
/// 越界 payload;与批量转笔记 50 条上限同为批次护栏,但删除是
22-
/// 原子全删,超限直接拒绝而非截断(防误删)。
23-
const BATCH_DELETE_LIMIT: usize = 200;
22+
/// @ai-context Why 有界:勾选集恒为当前可见列表子集(≤单页上限),上限只是
23+
/// 防御越界 payload;与批量转笔记 50 条上限同为批次护栏,但删除
24+
/// 是原子全删,超限直接拒绝而非截断(防误删)。
25+
/// @ai-context: 口径说明(审查注 pre-existing,不加新行为):运行中(recording)
26+
/// 会话可否删除与单条 delete_session 完全同口径——数据层无运行中
27+
/// 豁免,两条路径同样受 UI 勾选入口与确认弹窗约束。
28+
const BATCH_DELETE_LIMIT: u64 = LIST_LIMIT_MAX;
2429

2530
/// 批量删除核心编排(仅测试基准——命令入口走同函数后补域广播)。
2631
///
@@ -31,7 +36,7 @@ pub fn run_batch_delete(db: &crate::db::Db, ids: Vec<i64>) -> Result<BatchSessio
3136
if ids.is_empty() {
3237
return Err("批量删除 ids 不能为空".to_string());
3338
}
34-
if ids.len() > BATCH_DELETE_LIMIT {
39+
if ids.len() as u64 > BATCH_DELETE_LIMIT {
3540
return Err(format!("批量删除上限 {} 条", BATCH_DELETE_LIMIT));
3641
}
3742
if let Some(&bad) = ids.iter().find(|&&id| id <= 0) {

‎app/src-tauri/src/commands_session_delete_tests.rs‎

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,10 @@
44
//! 被测对象 run_batch_delete 为命令核心(注入 Db,无 Tauri 态),
55
//! 域广播在命令宏入口、不在此测试(与批量转笔记测试同构)。
66
//! @ai-context: 原子性用 SQLite RAISE(ABORT) 触发器模拟中途失败——真实语句
7-
//! 失败难以在 :memory: 造出,触发器是 schema 层最接近的失败源。
7+
//! 失败难以在 :memory: 造出,触发器是 schema 层最接近的失败源;
8+
//! COMMIT 期失败(P3-3 修复回归)用 defer_foreign_keys + 无级联
9+
//! FK 守卫表模拟——删除全过、违约推迟到提交,专打"提交失败
10+
//! 悬挂事务"死法(见 batch_delete_commit_failure_* 用例)。
811
912
use crate::commands_session_delete::run_batch_delete;
1013
use crate::db::Db;
@@ -178,6 +181,43 @@ fn batch_delete_coexists_with_batch_convert() {
178181
assert!(db.get_session(id).expect("db").is_none());
179182
}
180183

184+
#[test]
185+
fn batch_delete_commit_failure_rolls_back_without_hanging_tx() {
186+
// 批 7 审查修复(P3-3):COMMIT 期失败不得悬挂事务——原手写
187+
// BEGIN/COMMIT 在 COMMIT 报错路径不 ROLLBACK,SQLite 对提交期违约
188+
// (延迟外键)保留打开的事务,后续语句全部落入旧事务。
189+
// 造法:无 ON DELETE 的 FK 守卫表 + defer_foreign_keys=ON——删除语句
190+
// 全部通过、违约推迟到 COMMIT(真实语句失败难以在 :memory: 造出,
191+
// 延迟外键是 schema 层最贴近 COMMIT 失败语义的失败源)。
192+
let db = mem_db();
193+
let a = finished_session(&db, "A课");
194+
db.with_conn(|conn| {
195+
conn.execute_batch(
196+
"CREATE TABLE batch_commit_guard (
197+
id INTEGER PRIMARY KEY,
198+
session_id INTEGER NOT NULL REFERENCES sessions(id)
199+
);",
200+
)?;
201+
conn.execute(
202+
"INSERT INTO batch_commit_guard (session_id) VALUES (?1)",
203+
rusqlite::params![a],
204+
)?;
205+
conn.execute("PRAGMA defer_foreign_keys = ON", [])?;
206+
Ok(())
207+
})
208+
.expect("seed deferred-fk guard");
209+
// Act:删除命中 COMMIT 期 FK 违约(守卫行无级联可依)
210+
let err = run_batch_delete(&db, vec![a]).expect_err("commit must fail");
211+
// Assert:错误上抛且 A 仍在(transaction() 析构回滚——无半删残留);
212+
// 连接未悬挂:后续批量删除照常成功(旧实现会卡在"事务中开事务")
213+
assert!(err.contains("FOREIGN KEY"), "err={err}");
214+
assert!(db.get_session(a).expect("db").is_some(), "COMMIT 失败须整体回滚");
215+
let b = finished_session(&db, "B课");
216+
let later = run_batch_delete(&db, vec![b]).expect("later batch delete works");
217+
assert_eq!(later.deleted, 1);
218+
assert!(db.get_session(a).expect("db").is_some(), "悬挂事务会把报错的删除迟到提交");
219+
}
220+
181221
#[test]
182222
fn batch_delete_validation_and_counting() {
183223
let db = mem_db();

‎app/src-tauri/src/db_sessions.rs‎

Lines changed: 22 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -141,34 +141,33 @@ impl Db {
141141
/// @ai-context: 返回实际删除行数(已不存在/重复 id 计 0,不报错——与单条
142142
/// delete_session 对不存在会话返回 Ok(false) 的宽容语义一致);
143143
/// ids 去重由命令层负责,本层不假设输入形状。
144+
/// @ai-context: 审查修复(P3-3):改 rusqlite transaction()(Drop 自动回滚)
145+
/// ——原手写 BEGIN/COMMIT 的 COMMIT 失败路径不执行 ROLLBACK,
146+
/// SQLite 对提交期失败(如延迟外键违约)会保留打开的事务,
147+
/// 悬挂在共享连接上,后续语句全部落入旧事务、迟到 COMMIT 会
148+
/// 把"报错称已删"的半删状态一并提交。Transaction 值在任意
149+
/// 失败路径(含 commit() 报错)析构即回滚,无悬挂面;同
150+
/// db_notes/db_fragments 批删除手法。
144151
pub fn delete_sessions_batch(&self, ids: &[i64]) -> Result<usize> {
145152
if ids.is_empty() {
146153
return Ok(0);
147154
}
148-
self.with_conn(|conn| {
149-
conn.execute("BEGIN TRANSACTION", [])?;
150-
let result = (|| -> rusqlite::Result<usize> {
151-
let mut deleted = 0usize;
152-
{
153-
let mut stmt = conn.prepare("DELETE FROM sessions WHERE id = ?1")?;
154-
for &id in ids {
155-
deleted += stmt.execute(params![id])?;
156-
}
157-
}
158-
Ok(deleted)
159-
})();
160-
match result {
161-
Ok(n) => {
162-
conn.execute("COMMIT", [])?;
163-
Ok(n)
164-
}
165-
Err(e) => {
166-
let _ = conn.execute("ROLLBACK", []);
167-
Err(e)
168-
}
155+
// 事务需要 &mut Connection(with_conn 只给 &Connection——同
156+
// db_note_groups::delete_group 手法:直接锁 + conn.transaction())
157+
let mut conn = self
158+
.conn
159+
.lock()
160+
.unwrap_or_else(|poisoned| poisoned.into_inner());
161+
let tx = conn.transaction()?;
162+
let mut deleted = 0usize;
163+
{
164+
let mut stmt = tx.prepare("DELETE FROM sessions WHERE id = ?1")?;
165+
for &id in ids {
166+
deleted += stmt.execute(params![id])?;
169167
}
170-
.map_err(Into::into)
171-
})
168+
}
169+
tx.commit()?;
170+
Ok(deleted)
172171
}
173172

174173
// ── REQ-282(v0.19.6):标题内容化 A 层 ──

0 commit comments

Comments
 (0)