Skip to content

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

Closed

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. added
    domain:servicesRetired 2026-10-22, alias of domain:server (services surface); in-flight cards keep it until landed
    findingRecorded observation — held, not dispatchable until the findings triage round grades it
    needs-user-decisionBlocked on a maintainer decision — do not dispatch; decision cards only, never on a PR
    and removed
    findingRecorded observation — held, not dispatchable until the findings triage round grades it
    on Aug 5, 2026
  2. 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

  3. 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

  4. added
    pm:queueReady for the PM dispatch loop
    and removed
    needs-user-decisionBlocked on a maintainer decision — do not dispatch; decision cards only, never on a PR
    on Aug 6, 2026
  5. self-assigned this
    on Aug 6, 2026
  6. 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

Labels

domain:servicesRetired 2026-10-22, alias of domain:server (services surface); in-flight cards keep it until landedpm:queueReady for the PM dispatch loop

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions