fix(plugin-email): sys_email 的 queued 行在启动时被清扫,drain 失败升为 error (#5161) - #5191
Merged
Conversation
…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
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
Contributor
📓 Docs Drift CheckThis PR changes 1 package(s): 4 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
|
…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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #5161
基于 #5173 合并后的 main(
9c4f1743c)工作。问题
sys_email的status: 'queued'只有一个消费时机:insert 当时的afterInsertdrain 钩子(以及 #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 推到同一行上。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 钩子,也可能是多实例部署下另一个实例的同样两者。清扫它就等于把别人手里的活抢过来发第二遍。年龄门之下还有两道兜底(不是许可):本进程
send()正在持有的行(isServiceManaged)不碰;已经带message_id或已不是queued的行跳过 —— 与email.send.async订阅者投递前的幂等守卫同一套语义。诚实交代边界:内联模式没有跨进程协调,本来也从来没有(drain 钩子同样如此),年龄门就是那里的全部保护。多实例的持久投递正是队列模式存在的理由,在那里幂等键使重复发布成为 no-op。
3. 边界与可见性
created_at升序;多读一行判断是否截断,所以"还有更多"是事实而不是"整页=可能还有"的推断,截断时日志明说下次 boot 继续。failed > 0时另有一行 error,带后果与修复。kernel:readyhandler 抛错会被吞(core: kernel:ready 钩子抛错在 ObjectKernel 上失败 boot、在 LiteKernel 上被吞成一条 error 日志 —— 同一钩子两套失败语义 #5170),对一个耐久性清扫来说那等于把它存在的意义本身丢掉。需要确定性的调用方(测试)等EmailServicePlugin.outboxSweepSettled。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,全绿)。验收对照
{ rowId }作业并由真实DbQueueAdapter的一次 poll 推到sent,内联模式直接推到sent;不可发送的行推到failed而不是继续滞留。send()仍内联投递、app 写入的行仍由 afterInsert 钩子恰好投递一次,清扫在这两种情况下scanned: 0。测试
新增用例:
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