Skip to content

fix(plugin-email): sys_email 的 queued 行在启动时被清扫,drain 失败升为 error (#5161) - #5191

Merged
os-zhuang merged 3 commits into
mainfrom
claude/issue-5161-sys-email-queued-sweep
Aug 4, 2026
Merged

fix(plugin-email): sys_email 的 queued 行在启动时被清扫,drain 失败升为 error (#5161)#5191
os-zhuang merged 3 commits into
mainfrom
claude/issue-5161-sys-email-queued-sweep

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Fixes #5161

基于 #5173 合并后的 main(9c4f1743c)工作。

问题

sys_emailstatus: 'queued' 只有一个消费时机:insert 当时的 afterInsert drain 钩子(以及 #5160 之后 send() 自己发出的 email.send.async 作业)。此后没有任何东西再看这一行。进程在 insert 之后、投递完成之前死掉,或者 drain 的投递抛错,这一行就永远停在 queued —— 一个以队列命名、却没有读者的状态,而调用方早就被告知"消息已接受"。

实现

1. 启动清扫 sweepStrandedOutbox(新文件 outbox-sweep.ts)

挂在 kernel:ready,位置在 email.send.async 订阅者注册与 #5160 的 boot 门之后 —— 注册表定型、订阅者已就位,重新入队的行才有地方落。按当前模式分流:

  • 队列模式:通过 EmailService.enqueuePersistedRow 发布 { rowId }。这是刻意复用 send() 自己的生产者:同一个 EMAIL_SEND_QUEUE 常量、同一份 publish options、同一个 sys_email: + 行 id 的 idempotencyKey。第二个生产者用自己的方式拼载荷,正是队列两半漂移的起点;而共享的幂等键让"还挂着 pending 作业的行"被清扫时收敛到那个作业上,而不是把第二个 worker 推到同一行上。
  • 内联模式(以及 publish 失败时的回退):deliverPersistedRow 就地把行推进到 sent/failed —— 进程没死的话 drain 钩子本来就会做这件事。publish 失败回退内联,与 send() 自身的判断一致:行已经提交了,把它继续留给"没人"才是本 issue 要修的 bug。

2. 捞取判据:只捞"够老"的行(5 分钟)

这是 PR 里最需要说清楚的一条。合格条件是 status='queued'created_at 早于 now - 5min,而不是"早于本次 boot"。理由:

  • 几秒前插入的行不是滞留,而是某个人正在处理的在途工作 —— 可能是本进程 send() 的 insert 与 transport.send 之间,可能是本进程 setTimeout(0) 延后的 drain 钩子,也可能是多实例部署下另一个实例的同样两者。清扫它就等于把别人手里的活抢过来发第二遍。
  • "本次 boot 时间"在多实例下没有意义:兄弟实例一秒前插入的行比我的 boot 还新,但它显然不是我的。年龄是唯一在每个实例上含义相同的属性
  • 5 分钟这个值:内联重试循环自己的退避上限是 2s,所以一次活着的投递要么早已定稿要么早已抛错;同时短到崩溃后重启仍能在同一个维护窗口里把信发出去。

年龄门之下还有两道兜底(不是许可):本进程 send() 正在持有的行(isServiceManaged)不碰;已经带 message_id 或已不是 queued 的行跳过 —— 与 email.send.async 订阅者投递前的幂等守卫同一套语义。

诚实交代边界:内联模式没有跨进程协调,本来也从来没有(drain 钩子同样如此),年龄门就是那里的全部保护。多实例的持久投递正是队列模式存在的理由,在那里幂等键使重复发布成为 no-op。

3. 边界与可见性

4. drain 钩子失败升 error

两处 catch 从 warn 升到 error,并按 AGENTS.md 的 degradation-log-level 标准带上后果(这封信没有发出、行停在 queued、本进程不会再重试)与修复(下次重启的清扫会捞;要让失败被重试和进 DLQ 就打开 Settings → Mail → "Durable queue delivery")。同时把 deliverPersistedRow 加进 DURABILITY_CRITICAL_CALLEES,以后再有 catch 把它悄悄降级会被 pnpm check:durability-log-level 挡住(该 gate 现为 14 个 seam,全绿)。

验收对照

  • 人为构造"insert 后进程死亡"的行:测试直接把行写进表(不触发钩子),重启后队列模式发出 { rowId } 作业并由真实 DbQueueAdapter 的一次 poll 推到 sent,内联模式直接推到 sent;不可发送的行推到 failed 而不是继续滞留。
  • drain 失败的日志级别与文案有用例钉住:断言 error 通道里有这行、warn 通道里没有,并逐条钉后果/修复/成因文本。
  • 默认路径不变:正常 send() 仍内联投递、app 写入的行仍由 afterInsert 钩子恰好投递一次,清扫在这两种情况下 scanned: 0

测试

pnpm --filter @objectstack/plugin-email test    # 13 files / 195 tests passed
pnpm --filter @objectstack/plugin-email typecheck   # clean
node scripts/check-durability-degradation-log-level.mjs   # 14 seams, all loud
node scripts/check-startup-registry-verdict.mjs           # 40 seams, none recording a verdict
npx eslint packages/plugins/plugin-email/src --max-warnings=0   # clean

新增用例:outbox-sweep.test.ts(14 例,含年龄门、路由、兜底、边界与失败路径)、email-plugin.outbox-sweep.test.ts(10 例,真实 DbQueueAdapter 端到端 + drain 日志级别 + 默认路径)。

影响面

只动 packages/plugins/plugin-email/src/{email-plugin,email-service,index}.ts 与新增文件,外加 scripts/check-durability-degradation-log-level.mjs 的一条词表。未碰 headers/attachments 相关的任何东西(留给 #5177)、未碰 packages/spec、未碰 content/docs/releases/。用户可见,已带 changeset。


🤖 Generated with Claude Code

https://claude.ai/code/session_017MCKJaEomEqg4tvz4SzdNd


Generated by Claude Code

…t a failed drain at error (#5161)

`status:'queued'` had exactly one consumer — the afterInsert outbox drain that
fires during the insert itself (plus, since #5160, the email.send.async job
send() publishes). A process that died between the insert and the delivery, or
a drain whose delivery threw, left the row at `queued` forever: a state named
after a queue with no reader, with the caller already told the message was
accepted.

- `sweepStrandedOutbox` runs once per boot at kernel:ready, after the queue
  subscriber and the #5160 boot gate. Queue mode publishes `{ rowId }` through
  EmailService.enqueuePersistedRow (send()'s own producer, options and
  `sys_email:<id>` idempotency key, so a row with a pending job collapses onto
  it); inline mode finalizes the row in place via deliverPersistedRow.
- Only rows older than OUTBOX_SWEEP_MIN_AGE_MS (5m) are eligible — a young row
  is somebody's in-flight work, on this instance or a sibling, and age is the
  only property that means the same thing on every instance. Service-managed
  rows and rows carrying a message_id are skipped. Batch bounded at 500,
  oldest first, truncation reported.
- Boot does not await the sweep; it self-catches and reports at error, since a
  throwing kernel:ready handler is swallowed on LiteKernel (#5170).
- Both drain-hook catches now log at error with the consequence (the message
  was NOT sent, the row stays at `queued`) and the fix, per the AGENTS.md
  degradation-log-level rule, and deliverPersistedRow joins
  DURABILITY_CRITICAL_CALLEES so the level cannot regress.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017MCKJaEomEqg4tvz4SzdNd
@vercel

vercel Bot commented Aug 4, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectstack Ignored Ignored Aug 4, 2026 8:35am

Request Review

@github-actions github-actions Bot added size/xl documentation Improvements or additions to documentation tests tooling labels Aug 4, 2026
@github-actions

github-actions Bot commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/plugin-email.

4 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/automation/flows.mdx (via @objectstack/plugin-email)
  • content/docs/deployment/environment-variables.mdx (via @objectstack/plugin-email)
  • content/docs/plugins/packages.mdx (via @objectstack/plugin-email)
  • content/docs/protocol/kernel/index.mdx (via @objectstack/plugin-email)

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

…l cause class

A row whose message cannot be reconstructed is already recorded as `failed` by
deliverPersistedRow, so the only way into this catch is the datasource or the
queue. Say that instead of sending the operator to inspect the row's columns.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017MCKJaEomEqg4tvz4SzdNd
…jectQL's own dispatch (#4550)

`check:engine-double-contract` flagged the new fake engine in
email-plugin.outbox-sweep.test.ts: its `delete()` filtered on `where.id`
directly instead of routing through `assertEngineDeleteDispatch`, which is
looser than the engine it stands in for on exactly the case a hand-written
mirror drops (`where: { id: { $in: [...] } }` reads as an id and is a
multi-row predicate the real engine rejects without `multi`).

Same shape as the sibling double in email-plugin.queue-delivery.test.ts
(b169f21); `@objectstack/objectql` is already a devDependency.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017MCKJaEomEqg4tvz4SzdNd
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/xl tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

plugin-email: sys_email 的 queued 行崩溃后永久滞留 —— 无任何轮询者,且 drain 钩子把失败 warn 掉

2 participants