Skip to content

plugin-email: insert 返回不同 id 时,outbox drain 钩子在 insert 窗口内读不到 managed 标记 —— 与 send() 自己的投递构成双发竞态 #5523

Description

@os-zhuang

发现于 #5169(修 managedRowIds 泄漏)实现期间,读同一段 try/finally 时看到。与 #5169 的 diff 无关(那单修的是"加了不删",这单是"加得太晚"),因此未在其 PR 里顺手改;按"观察级"归档,严重程度请 PM triage 判定。

核验对象:origin/main @ 308c7095143c5001ed3a061236bed523df910c4b。

机理

packages/plugins/plugin-email/src/email-service.ts sendInternal():

this.managedRowIds.add(id);            // id = 自己 newId() 生成的
try {
  const res = await this.options.persistence.insert(baseRow);   // ← 钩子在这里面同步跑
  persistedId = typeof res === 'string' ? res : res?.id ?? id;
  if (persistedId !== id) this.managedRowIds.add(persistedId);  // ← 只有 insert 返回后才加

packages/plugins/plugin-email/src/email-plugin.ts afterInsert drain 钩子(约 553-560 行)读的是插入结果行自己的 id:

const rowId = row.id != null ? String(row.id) : '';     // row = hookCtx.result
if (!rowId || svc.isServiceManaged(rowId)) return;      // 跳过 send() 自管的行

自建 persistence 若给行分配自己的主键(数据库自增 PK、外部投递系统回执 id),hookCtx.result.id 就是那个新 id,而此刻 managedRowIds 里只有 send() 生成的旧 id —— isServiceManaged(新 id) 为 false,钩子于是认定"这是应用自己插的 outbox 行",setTimeout(…, 0) 后 deliverPersistedRow() 投递一次;send() 随后又按自己的路径投递(inline 直接发,queue 模式发 job)。同一封邮件两次投递、同一行两次终态更新。

唯一的挡箭牌是竞态:延迟的 drain 会重读行,若 send() 的 inline 投递已把行推到 sent 就会中止。但 transport.send 是真实网络 I/O,通常比 0ms 定时器慢,所以先跑到的是 drain —— 靠 race 兜的正确性,不是设计。

今天为什么打不到(与 #5169 同一根引线)

仓内唯一实现原样回传 id:

return created?.id ? { id: String(created.id) } : { id: String(row.id) };

ObjectQL insert 回传入参 row.id,所以 persistedId === id,钩子读到的就是已 managed 的那个 id,正常跳过。EmailPersistence 是导出的公开接口(insert(row): Promise 返回 { id: string } 或 string),任何"我自己决定行 id"的实现即点燃。

为什么不是 #5169 的子项

#5169 的完成范围是"reserve 了要 release"(finally 里补删),已由 PR 修掉,且不改变本条:send() 在 insert 返回前无法知道那个 id,补删的时机与本条的读时机不在同一侧。本条的修法要么在 drain 侧、要么在契约侧,是独立的一个决定:

  • A(收紧契约):声明 EmailPersistence.insert 必须回传 row.id(返回值只作确认),persistedId !== id 这条分支连同 plugin-email: EmailService.managedRowIds 泄漏 persistedId —— insert 返回不同 id 时永不清理 #5169 修的 release 一起删掉。合"declared = enforced":publish/wire 期校验,自建实现改 id 立刻响,而不是靠竞态。代价:对(目前为零的)自建实现是 breaking,且要说明为什么 id 必须由 service 铸造(附件 storage key sys_email/attachments/行id/… 已经先于 insert 用了这个 id —— 这本身就是"id 必须是 service 铸的"的一条现存证据)。
  • B(让钩子认得出):drain 钩子改为不只按 result.id 判定(例如同时看入参行的 id,或 send() 在 insert 前把"本次调用铸的 id"以别的方式标记给钩子)。保留"persistence 可以改 id"这一自由度,但要在钩子侧多维护一条对应关系。

我倾向 A:公开接口上"随便换 id"这个自由度目前没有任何业务拉力(仓内零使用,示例应用零使用),而它换来的是一条只能靠 0ms 定时器和网络延迟大小来决定发一次还是两次的路径 —— 正是"AI 写的元数据/集成代码最容易踩、且踩了不响"的那类宽容消费端。不过这条改的是公开接口语义,需维护者定,故只归档、不带 pm:queue。

复现用例(写出来即红)

const persistence = { async insert() { return { id: 'db-pk-7' }; }, async update() {} };
// 走 email-plugin 的 afterInsert 钩子:hookCtx.result.id = 'db-pk-7'
// 断言:钩子被调用时 svc.isServiceManaged('db-pk-7') 应为 true(今天是 false)

Related-to: #5169

Activity

  1. os-zhuang commented on Aug 5, 2026

    @os-zhuang
    ContributorAuthor

    发现分诊轮判级(services 车道 PM,session_01BWS4heBoAitLmzCLhcYdbK,2026-08-05):晋级为决策卡(摘 finding 挂 needs-user-decision)。

    理由:缺陷真实(自建 persistence 分配新 id 时,drain 钩子与 send() 双发,唯一挡箭牌是 0ms 定时器 vs SMTP 延迟的竞态),但两案分叉在公开接口语义上,过升级门槛:

    • A(收紧契约):声明 EmailPersistence.insert 必须回传 row.id(返回值仅作确认),persistedId !== id 分支连同 plugin-email: EmailService.managedRowIds 泄漏 persistedId —— insert 返回不同 id 时永不清理 #5169 的 release 一起删。三轴:「随便换 id」的自由度实测零拉动(仓内、示例、cloud 均零使用),且附件 storage key 已先于 insert 使用 service 铸的 id —— 现存证据说明 id 本就该 service 铸;declared = enforced,publish/wire 期校验响亮拒绝。对(当前为零的)自建实现是 breaking。
    • B(改钩子口径):保留自由度,drain 钩子多维护一条对应关系 —— 宽容消费端,AI 集成代码最易踩且踩了不响的形状。

    建议 A,与 issue 内 dev 倾向一致,三轴同向。您拍板后:A → 入队(plugin-email + spec 侧若 EmailPersistence 声明在 spec 则拆单);B → 入队改钩子。


    Generated by Claude Code

  2. os-zhuang commented on Aug 6, 2026

    @os-zhuang
    ContributorAuthor

    维护者裁决(2026-08-06,经 services 车道 PM 转达):批准 A 案(收紧契约)

    EmailPersistence.insert 必须回传 row.id(返回值只作确认);persistedId !== id 分支连同 #5169 补的 release 一并删除;publish/wire 期校验 —— 自建实现改 id 立刻响,不再靠 0ms 定时器 vs 网络 I/O 的竞态兜底。理由要点:换 id 自由度仓内/示例零使用、无业务拉力;附件 storage key 在 insert 之前已用 service 铸的 id,「id 必须 service 铸造」有现存证据;对目前为零的外部实现 breaking,changeset 写清 FROM→TO 语义。

    摘 needs-user-decision 回 pm:queue,本车道随后认领直派。


    Generated by Claude Code

  3. self-assigned this
    on Aug 6, 2026
  4. os-zhuang commented on Aug 6, 2026

    @os-zhuang
    ContributorAuthor

    认领(services 车道 PM 派发,session_01BWS4heBoAitLmzCLhcYdbK)


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions