feat(plugin-email): 大附件走 storage 引用,内容随行终态回收 (#5172) - #5268
Merged
Conversation
…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
…achment-storage-refs
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
Contributor
📓 Docs Drift CheckThis PR changes 2 package(s): 5 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
|
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 #5172
二期:超过 256 KiB 的附件内容经
file-storagecapability 存储,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前缀点名归属对象。运维在 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 列表的人看的。/、..、控制字符;它是审计元数据,住在行里,不住在路径里。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. 上传失败语义:退回内联,响亮(已裁定,按裁定实现)
路由表(每格都有用例):
info(维持 #5173 钉住的现行为,文案改为点名要挂什么)error,一次性「无 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模块头里,不用一个只在一种适配器上成立的清扫去糊。契约面
packages/spec未动。EmailAttachment不变(调用方照旧传content),IStorageService不变。resolveAttachmentStore()取file-storage,类型是本包声明的结构化接口EmailAttachmentStore(只含upload/download/delete),照 fix(objectql,service-queue): lifecycle settings 覆盖不再能绕过消费者的保留窗下限 (#5195) #5210LifecycleFloorRegistrar的做法 —— 没有新增getService< any >站点,check:slot-lookup的 baseline 一个没涨。理由不是洁癖:这三个调用失败起来都是隐形的,x.upload is not a function掉进 offload 自己的try里,长得跟「运维没装 storage」一模一样,于是邮件悄悄不再持久而日志在怪部署。StorageMetadataStore把每一次引擎写入失败都静默吞掉并退回进程内存 ——sys_file的「business truth」可能凭空消失且无一行日志 #5216/fix(service-storage)!: 引擎写入/读取失败不再伪装成功 —— sys_file 业务真相丢失时响亮失败 (#5216) #5232 把存储元数据层做响亮了,这里直接受益:取不到就抛,deliverPersistedRow把原因写进行的failed,队列重试/进 DLQ。取回的字节还要过size+hash校验 —— 后端返回截断对象,和列被截断一样不可接受,而以前一样看不见。测试
新增用例(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 / 上传失败两种退回。未在范围内的发现
IStorageService.list(prefix)means two different things on the two shipped adapters (local: one level, directories as files; S3: recursive, silently capped at 1000) #5266(finding,未派):IStorageService.list(prefix)在两个自带适配器上语义不同(local 单层且把目录当文件、S3 递归但静默截断在 1000)。本单因为这条差异放弃了列举驱动的回收方案,改走队列延迟任务。今天没有生产消费方,故按 observation-class 归档,严重性交 PM 分诊。🤖 Generated with Claude Code
https://claude.ai/code/session_017MCKJaEomEqg4tvz4SzdNd
Generated by Claude Code