fix(网关): 附件竞态回滚一件都退不掉 —— BindRelayMail 早于 attachAll 导致撞外键
## 缺陷
`/mail/send` 的回滚块注释写着「三件事都要退:邮件本身、本次往返预算、relay
幂等键」,但实际**一件都退不掉**:
relayed_mails.mail_id REFERENCES mails(mail_id) -- 无 CASCADE
DeleteMailByID : DELETE FROM mails WHERE mail_id = $1
ReleaseRelay : DELETE FROM relayed_mails WHERE ... AND mail_id IS NULL
`BindRelayMail` 原来在 `attachAll` **之前**执行,所以走到回滚块时该键已经绑上了:
- `DeleteMailByID` 撞外键失败(生产 DSN 有 `foreign_keys(1)`)⇒ 邮件留在库里;
- `ReleaseRelay` 的 WHERE 是 `mail_id IS NULL`,对已绑定的行是 no-op ⇒ 键没退。
后果与注释想避免的正好相反:发件方收到 4xx 会重试,收件方看到那封残余邮件 ——
两封。
## 修法
把 `BindRelayMail` 挪到回滚块**之后**。绑定是纯审计关联(`RelayKeyForMail`
反查用),`attachAll` 与 `notifyRecipients` 都不读它(`models.Mail` 零 relay
字段),所以推迟没有副作用。
## 判据
`TestMailSendRollbackRemovesMailAndRelayKey`:同一个 `attachment_id` 传两次 ——
`checkAttachable` 逐条查时都还没挂载(都通过),`attachAll` 的原子 UPDATE 在
第二条改到 0 行 ⇒ 409 ⇒ 回滚。这样无需真并发就能确定性地走到那条路径。
三条断言:① 触发条件成立(409,否则用例会静默退化成空跑);② 邮件没留下;
③ 幂等键退回去了。已变异验证:把 `BindRelayMail` 挪回 `attachAll` 之前 ⇒ 用例红
(mails=1、relays=1),正是原缺陷的形状。
`me.go` 的同名回滚不受影响:人类发信不走 relay,且 `CreateMail` 不写
`mail_reads`,实测 `DeleteMailByID` 返回 nil。
`go test ./...` 13 包全绿;handler 组 -race 通过;gofmt/vet 干净。
This commit is contained in:
@ -481,10 +481,6 @@ func SendMail(w http.ResponseWriter, r *http.Request) {
|
||||
Error(w, http.StatusInternalServerError, "Failed to create mail")
|
||||
return
|
||||
}
|
||||
if relay != "" {
|
||||
// 关联失败不影响功能,只是少一条审计记录
|
||||
_ = repo.BindRelayMail(r.Context(), agentName, relayKey, mailID)
|
||||
}
|
||||
if proposal != nil {
|
||||
// 记不上提议不该让发信失败:邮件本身已经入库,提议是旁支信息
|
||||
_ = repo.SetMailRenameProposal(r.Context(), mailID, proposal.Alias, proposal.Reason)
|
||||
@ -497,6 +493,13 @@ func SendMail(w http.ResponseWriter, r *http.Request) {
|
||||
//
|
||||
// 三件事都要退:邮件本身、本次往返预算、relay 幂等键。
|
||||
// 错误均忽略:响应已由 attachAll 写出,回滚失败只能记日志。
|
||||
//
|
||||
// 次序要紧:**必须在这里还没 BindRelayMail 时才可能真正回滚**。
|
||||
// relayed_mails.mail_id 对 mails 是外键且**无 CASCADE**,一旦绑定,
|
||||
// 上面那句 DELETE 会直接撞外键失败,邮件留在库里;而 ReleaseRelay 的
|
||||
// WHERE 是 `mail_id IS NULL`,对已绑定的行也是 no-op —— 于是"三件事
|
||||
// 都要退"一件都退不掉,发件方收到 4xx 重试,收件方还会看到那封残余邮件。
|
||||
// 所以 BindRelayMail 放在这个回滚块**之后**(见下)。
|
||||
_ = repo.DeleteMailByID(r.Context(), mailID)
|
||||
if !relayFree {
|
||||
repo.RefundSessionBudget(r.Context(), sessionID)
|
||||
@ -507,6 +510,15 @@ func SendMail(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if relay != "" {
|
||||
// 关联失败不影响功能,只是少一条审计记录。
|
||||
//
|
||||
// 放在 attachAll **之后**:上面那条回滚路径要能真的删掉邮件,就必须
|
||||
// 在删的时候还没有任何行引用它。绑定是纯审计关联,notify 的载荷里
|
||||
// 没有 relay 字段(models.Mail 零 relay 字段),所以推迟到这里没有副作用。
|
||||
_ = repo.BindRelayMail(r.Context(), agentName, relayKey, mailID)
|
||||
}
|
||||
|
||||
notifyRecipients(r.Context(), to, ccList, sessionID, mailID, agentName, req.Subject, parentIDString(parentMailID))
|
||||
|
||||
// 回传会话别名与本任务剩余往返,让发件方知道后续用什么地址续谈、还能发几封
|
||||
|
||||
Reference in New Issue
Block a user