Skip to content

feat(plugin-email): 大附件走 storage 引用,内容随行终态回收 (#5172) - #5268

Merged
os-zhuang merged 2 commits into
mainfrom
claude/issue-5172-attachment-storage-refs
Aug 4, 2026
Merged

feat(plugin-email): 大附件走 storage 引用,内容随行终态回收 (#5172)#5268
os-zhuang merged 2 commits into
mainfrom
claude/issue-5172-attachment-storage-refs

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Fixes #5172

二期:超过 256 KiB 的附件内容经 file-storage capability 存储,sys_email.attachments_json 元素存 storageKey + 永久审计元数据,worker 按 key 取回重建。一期(#5177/#5211)一行没动,storageKey 扩展点补上生产者与读取方,零迁移

基于合并了 origin/main 的最新状态(108ba8d0c)。

一句话说清这一刀

sys_email 是 append-only 审计日志,附件内容是投递工件。两者寿命不同,整个设计就是这条切口:

  • filename / contentType / size / hash 永久留在行里 —— 它们是"某个文件发给了某个收件人"的证据,一共约 100 字节;
  • 字节只活到「行终态 + 宽限窗」,之后删掉,元素里 storageKey 换成 contentReclaimedAt

没有这条切口,append-only 日志会无界增长二进制;有了它,日志按日志的方式增长。

三个实现细节(issue 点名要在 PR 里说明)

1. storage key 命名/前缀方案

sys_email/attachments/< rowId >/< NNN >-< hash16 >
  • sys_email/attachments 前缀点名归属对象。运维在 bucket 里看到这一坨,能立刻知道谁写的、谁决定它什么时候死。刻意不走 sys_file 元数据层:sys_file 是 "mostly-permanent business truth with compliance value"(它自己的注释),每个附件一行永久业务真相,正好复活本单要消灭的无界增长。这也是为什么本单不涉及 sys_file 审计豁免的问题 —— 根本没有产生 sys_file 行。
  • < rowId >/ 一封信的各部分归一个目录,手工清一封信就是清一个前缀;从 key 能反查行。rowId 先折叠到 [A-Za-z0-9_-] —— key 在本地适配器上就是路径,不允许一个 id 往里塞 /..(有用例)。
  • < NNN > 是元素在 attachments_json 里的下标,零填充所以 bucket 列表的排序 = 附件顺序。配合 rowId 使 key 确定:同一行重试上传覆盖自己的字节,不会留第二份没人引用的副本。
  • - < hash16 > 是行里也记着的摘要前 16 位。读取方本来就验全量摘要,这一段是给读 bucket 列表的人看的。
  • 文件名不进 key。文件名是作者提供的 UTF-8,可能含 /..、控制字符;它是审计元数据,住在行里,不住在路径里。

2. 宽限窗长度与回收触发机制 —— 选了「延迟队列任务」,不是同步删,也不是清扫

裁定给了「终态时同步删 vs 清扫式」两条,两条都过不了这张表:

  • 同步删根本承载不了宽限窗,而窗不是装饰:email.send.async 订阅者会重新投递停在 failed 的行,行一说 failed 就删内容,等于把队列自己的重试要用的字节删掉。
  • 清扫式要求查询结果集会缩小status IN ('sent','failed') AND 够老 不会:第一遍之后它每次返回的都是自己已经回收过的行,每次 boot 重扫同一页远古行。反过来从 storage 侧扫(列前缀、逐个问行)则不可移植:LocalStorageAdapter.list 是单层 readdir(连 …/< rowId >/< NNN > 都看不见),S3 的是递归且不翻页。一个在两种自带适配器上悄悄含义不同的机制,比没有机制更糟。(这条差异已另开 finding,见下。)

所以触发是终态时发布一个延迟队列任务:email.attachment.reclaim,delay = 24h,按行去重(idempotencyKey: sys_email_attachments:< rowId >)。durable(就是 sys_job_queue 行,和投递同一套底座)、精确(不扫不轮询)、必然可用(内容只在队列投递生效时才出行,所以要回收它的队列就是当初入队的那个队列)。

24h 这个数,常量不是配置项(和 256 KiB 同一理由:旋钮是存储预算漂移的第二个地方)。它要同时越过三件事,这是同时越过的最小整数:队列自己的重试跨度(退避封顶 5 分钟,再宽的 attempt 预算也在一小时内耗尽)、租约过期导致的重复投递、运维当天的「这封发错了,到底发了什么?」。过了一天,后一个问题由永不消失的审计元数据回答。

worker 落地前还会重读行:行还在 queued → 不删,重新排一个整窗;行终态但 updated_at 落在窗内(重试重新盖了时间戳)→ 不删,补剩余时间。这个容忍只能推迟删除、不能提前删除,这是一个破坏性操作上的容忍唯一可以指的方向。

3. 上传失败语义:退回内联,响亮(已裁定,按裁定实现)

路由表(每格都有用例):

情形 行为 级别
附件 ≤ 256 KiB 内联进行,入队(#5177)
> 256 KiB,队列 + storage 存 storage,入队
> 256 KiB,队列,无 storage 退回内联,整封投出 info(维持 #5173 钉住的现行为,文案改为点名要挂什么)
> 256 KiB,队列,上传失败 退回内联,整封投出 error,一次性
> 256 KiB,无队列 内联,根本不上传

「无 storage」保持 info:这是配置如此,结果与 #5172 之前逐字节相同,info#5173 已经钉住的级别。「上传失败」升 error:storage 挂着并且坏了,运维付钱买的持久性没生效而外面一切正常 —— AGENTS.md 里 durability 那一类,一次性说,带后果和修复。

「行被 retention 删掉」时内容如何回收 —— 论证

回收任务的载荷带的是 storage keys,不只是 rowId。 这是整个回收设计里最吃劲的一条。任务到点时行已经不在(未来某个 #5192 形状的声明式 retention 收走了、运维 purge 了、任何原因),它依然确切知道该删哪些字节,并且删。所以删行不是「丢失内容」的途径,而是「可以回收」的最强信号 —— 一个已经不存在的行,当然没有投递要做了。有 e2e 用例:发大附件 → 投递 → 用 multi: true 的谓词删除(LifecycleService 发的正是这个形状)→ 推进时钟 → poll → 字节没了,任务 completed

注意这条不依赖什么:不依赖「回收宽限窗必须短于行 retention」,不依赖行上有回收标记位,orphan 那一路甚至不需要读行。sys_email 今天是 lifecycle.class: 'record',spec 明确禁止在 record 类上声明 retention(object.zod.ts: "permanent business truth — retention/ttl/storage/archive policies are not allowed on it");上面这条保证正是为了让这一点将来改变时,没有人需要先想起这个文件。

另外两个 orphan 口子也堵了:部分上传失败(第 3 个挂了,前 2 个当场删掉)、上传成功但行 insert 失败(内容当场删掉,删不掉则 error)—— 两者都是「没有任何行会引用的字节」,都有用例。

诚实交代残余风险:回收任务本身丢了(队列被 purge、进了 DLQ 没人 replay),那些字节就留在 bucket 里。这是字节泄漏,不是正确性失败,而且可见(listFailed / DLQ),补它就要加上面刚论证过不该加的第二个扫除者。写在 attachment-reclaim.ts 模块头里,不用一个只在一种适配器上成立的清扫去糊。

契约面

测试

pnpm --filter @objectstack/plugin-email test        # 19 files / 291 tests passed(新增 60 例)
pnpm --filter @objectstack/plugin-email typecheck   # clean
pnpm --filter @objectstack/platform-objects test    # 9 files / 266 tests passed
pnpm --filter @objectstack/platform-objects typecheck  # clean
node scripts/check-engine-double-contract.mjs       # OK — 21 pinned(新增 2 个 double 全部 pinned)
node scripts/check-durability-degradation-log-level.mjs  # 14 seams, all loud
node scripts/check-slot-lookup-ratchet.mjs          # holds — no files added
node scripts/check-i18n-bundles.mjs --filter=platform-objects  # in sync (8 bundles)
pnpm --filter @objectstack/spec check:generated     # All 9 generated artifacts up to date
npx eslint packages/plugins/plugin-email/src packages/platform-objects/src/audit --max-warnings=0  # clean

新增用例(60 例,4 个文件):

  • attachment-storage.test.ts(23):key 方案(确定性、跨行隔离、.. 折叠、不含文件名)、offload 产出(有 storageKey、无 inline、审计元数据齐全、行体积 < 400 字节)、string/Buffer 两个 arm 的字节与类型往返、部分上传失败清理、读取侧五种失败(outage / key 不在 / 截断 / 等长替换 / 无 capability)全部拒绝、回收后列的形状与「已回收」专属拒绝文案、storageKeysInColumn 的刻意容忍。
  • attachment-reclaim.test.ts(13):终态回收后审计字段逐条不变、failed 终态同样回收、幂等、行被删(multi 谓词)仍按载荷删字节、行不可读也照删、queued / 窗内 / 无 store 三种重排、无法重排时 error 带后果+修复、删除失败抛出且不重写列(先删字节后写行的顺序)。
  • email-service.attachment-storage.test.ts(19):边界含等号两侧各一例(恰好 256 KiB 走内联且不碰 storage,+1 字节走 storage)、合计计算、四种响亮退回、sendInline 不上传、insert 失败清理 orphan、worker 重建的四条路径、回收任务的 payload/delay/幂等键、failed 也排、小附件不排、发布失败 error
  • email-plugin.attachment-storage.test.ts(5):DbQueueAdapter 全链路 —— send → 上传 → 行存引用 → job → poll → 下载 → transport 收到逐字节相同的 300 KB 附件 → sent → 延迟回收任务 → 窗内 poll 不动 → 过窗 poll 删净且审计元数据仍在;以及 retention 删行不留孤儿、小附件不受影响、无 storage / 上传失败两种退回。

未在范围内的发现


🤖 Generated with Claude Code

https://claude.ai/code/session_017MCKJaEomEqg4tvz4SzdNd


Generated by Claude Code

claude added 2 commits August 4, 2026 13:22
…ry covers them too (#5172)

Phase 2 of the sys_email payload work (#5177/#5211 shipped phase 1). Over the
256 KiB in-row budget, attachment content is uploaded to the `file-storage`
capability and the row carries `{ filename, contentType?, size, hash,
contentForm, storageKey }` instead of base64. The queue worker fetches the
content back and rebuilds the message, so a signed contract or an exported
report finally gets the same durability guarantee as every other message.

The cut the design turns on: filename/contentType/size/hash are PERMANENT
audit evidence and stay on the row forever; the bytes are a delivery artifact
and are deleted a grace window after the row reaches a terminal state, at
which point `storageKey` is replaced by `contentReclaimedAt`. That is what
decouples an append-only mail log from unbounded binary growth.

Reclamation is a delayed `email.attachment.reclaim` job whose payload carries
the storage keys, not just the row id — so a row deleted in the meantime
(a future declarative retention policy, a purge) reclaims its content instead
of orphaning it.

Every failure degrades to inline delivery of the WHOLE message and says so:
no capability mounted, and an upload that failed, are distinguished in the
log. On the read side nothing is swallowed — an unfetchable, truncated or
substituted object fails the row rather than putting a message on the wire
without an attachment it declares.

Co-Authored-By: Claude Fable 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 1:29pm

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 2 package(s): @objectstack/platform-objects, @objectstack/plugin-email.

5 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/platform-objects, @objectstack/plugin-email)
  • content/docs/protocol/kernel/index.mdx (via @objectstack/plugin-email)
  • content/docs/ui/setup-app.mdx (via @objectstack/platform-objects)

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.

@os-zhuang
os-zhuang marked this pull request as ready for review August 4, 2026 13:44
@os-zhuang
os-zhuang added this pull request to the merge queue Aug 4, 2026
Merged via the queue into main with commit 1b9a53b Aug 4, 2026
24 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-5172-attachment-storage-refs branch August 4, 2026 13:52
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 无附件/自定义 header 列,带附件的邮件拿不到队列投递的持久化保证(#5160 落地后)

2 participants