Rust in Everything
基于 sea-orm 2.0.4

after_save 返回了 Err,行却已经写进去了

主线位置:写入主线的两端。am.insert(&db) 是 before_save → INSERT ... RETURNING → after_save 三步,用的都是调用方给的 db,中间没有 BEGIN。钩子的参数和调用点见 save 与 ActiveModelBehavior 的「四个钩子和一个 new」「钩子里报错:什么被回滚了」,事务本身见事务。

sequenceDiagram
  participant App as 调用方
  participant AM as ActiveModel insert
  participant PG as Postgres
  App->>AM: insert(&db)
  AM->>PG: INSERT note(自动提交)
  AM->>PG: 钩子 INSERT audit(自动提交)
  AM-->>App: Err,来自 after_save
  Note over PG: 两行都留下了

本页实验在 M2 Max、macOS、rustc 1.99.0、Postgres 16.14 上运行,sea-orm 2.0.4,以及本站用的 1.1.20,同一份代码。下面的 wire> 行是夹在 sqlx 和 Postgres 之间的一个 TCP 代理打印的:客户端每执行一条语句打一行 SQL,wire< 是 ReadyForQuery 里事务状态的变化(I 空闲、T 事务中)。用代理而不是 sqlx 日志,是因为 sqlx 日志里没有 BEGIN 和排队的 ROLLBACK(见 sqlx 卷的事务没 commit)。

现象

在 after_save 里做一道检查或者写一条审计记录,失败时返回 Err。调用方拿到 Err,按「保存失败」处理,提示用户重试;用户重试后,表里出现了两条一样的记录。

最小复现

note 实体的钩子:先写一条 audit,再拒绝超过 10 个字节的标题(src/lib.rs):

#[async_trait::async_trait]
impl ActiveModelBehavior for ActiveModel {
  async fn after_save<C>(model: Model, db: &C, insert: bool) -> Result<Model, DbErr>
  where
    C: ConnectionTrait,
  {
    // write an audit row, then refuse titles that are too long
    let action = if insert { "insert" } else { "update" };
    db.execute_unprepared(&format!("INSERT INTO audit (note_id, action) VALUES ({}, '{action}')", model.id))
      .await?;
    if model.title.len() > 10 {
      return Err(DbErr::Custom(format!("title too long: {}", model.title.len())));
    }
    Ok(model)
  }
}

src/bin/after_save.rs 在连接池上直接插入(counts 数两张表的行数,它自己的查询不打印):

let r = note::ActiveModel { title: Set("a very long title".into()), ..Default::default() }.insert(&db).await;
println!("result: {r:?}");
println!("after: {}", counts(&db, &w).await?);
before: note=0 audit=0
--- 1. insert(&db), after_save returns Err
  wire> INSERT INTO "note" ("title") VALUES ($1) RETURNING "id", "title"
  wire> INSERT INTO audit (note_id, action) VALUES (1, 'insert')
result: Err(Custom("title too long: 17"))
after: note=1 audit=1

两条 INSERT 之前没有 BEGIN,连接一直是 I(空闲)状态,每条语句执行完就提交了。调用方拿到 Err,表里多了一条 note 和一条 audit。1.1.20 上输出逐行相同。

出处

ActiveModelTrait::insert 的全部内容:

        let am = ActiveModelBehavior::before_save(self, db, true).await?;
        let model = <Self::Entity as EntityTrait>::insert(am)
            .exec_with_returning(db)
            .await?;
        Self::after_save(model, db, true).await

没有 begin,db 原样传给 INSERT 和钩子。db 的类型是 C: ConnectionTrait,可以是 &DatabaseConnection(每条语句从池里取一条连接、自动提交),也可以是 &DatabaseTransaction,insert 不区分。update 是同样的三步,save 在两者之间选一个,delete 的 after_delete 也在 DELETE 执行之后才跑。trait 的文档也写得很清楚:before_save 是「Return an error to abort the operation」,after_save 是「Hook invoked after insert, update, and save succeed」。after_save 返回的 Err 只改变了调用方看到的结果,不能撤销已经执行的语句。

修法

把调用放进事务。 钩子拿到的 db 就是这个事务,它写的东西和 INSERT 在同一个事务里;出错时事务被 drop,一起回滚:

// the fix: the insert and its hooks in one transaction
async fn create_in_txn(db: &DatabaseConnection, title: &str) -> Result<note::Model, DbErr> {
  let txn = db.begin().await?;
  let m = note::ActiveModel { title: Set(title.into()), ..Default::default() }.insert(&txn).await?;
  txn.commit().await?;
  Ok(m)
}
--- 2. fix: begin, insert(&txn), commit
  wire> BEGIN
  wire< ReadyForQuery(T)
  wire> INSERT INTO "note" ("title") VALUES ($1) RETURNING "id", "title"
  wire> INSERT INTO audit (note_id, action) VALUES (2, 'insert')
result: Err(Custom("title too long: 17"))
  wire> ROLLBACK
  wire< ReadyForQuery(I)
after: note=1 audit=1
  wire> BEGIN
  wire< ReadyForQuery(T)
  wire> INSERT INTO "note" ("title") VALUES ($1) RETURNING "id", "title"
  wire> INSERT INTO audit (note_id, action) VALUES (3, 'insert')
  wire> COMMIT
  wire< ReadyForQuery(I)
result: Ok(Model { id: 3, title: "short" })
after: note=2 audit=2

失败的那次,? 让函数在 commit 之前返回,txn 被 drop,ROLLBACK 在连接归还时发出(result: 先打印,ROLLBACK 后出现,就是这个原因),行数仍是 1 和 1。成功的那次以 COMMIT 结束。被回滚的 2 号主键不会再用:序列不随事务回滚。1.1.20 上输出逐行相同。也可以用 db.transaction(|txn| Box::pin(async move { .. })) 闭包,返回 Err 时它替你回滚,见事务的「transaction 闭包与 TransactionError」。

能在写之前判断的,放进 before_save。 它返回 Err 时语句根本不发(见 save 与 ActiveModelBehavior),不需要事务就是干净的。after_save 适合放依赖数据库生成值(主键、默认值)的后续动作,并且要在事务里调用。

怎么确认没中招

  • 搜 impl ActiveModelBehavior,找出覆盖了 after_save / after_delete 并可能返回 Err 的实体,再找它们的 insert / update / save / delete 调用点,看传进去的是 &txn 还是 &db。本站所有实体的 ActiveModelBehavior 都是空 impl,不受影响。
  • 测试里让钩子失败一次,然后用另一条连接数行数;在同一个事务里数会看到自己未提交的写入。
  • 开着 RUST_LOG=sqlx=info 时看不到 BEGIN,但能看到 COMMIT:一次带钩子的写入后面没有 COMMIT,就是没在事务里。

小结

  • ActiveModel 的 insert / update / save / delete 不开事务,钩子和语句用的是调用方给的同一个 db。
  • 在 &DatabaseConnection 上,after_save 返回 Err 时,INSERT 和钩子自己写的东西都已经提交;调用方看到的是失败。
  • 修法:在 db.begin() 得到的事务里调用、成功后 commit,或用 transaction 闭包;能提前判断的检查放进 before_save。
  • 2.0.4 和 1.1.20 行为相同。

💬 关联讨论

发起讨论
加载中...