发现于 #5172(邮件大附件走 storage)的实现过程中 —— 本单不修,只记录。#5172 因为这条差异放弃了「列举 storage 前缀来回收内容」的方案,改为队列延迟任务驱动,所以本单不阻塞任何东西。
事实
IStorageService.list(prefix) 的契约文字是 "List files in a directory/prefix"(packages/spec/src/contracts/storage-service.ts)。两个自带适配器对同一个调用给出的答案在语义上不同:
LocalStorageAdapter.list (packages/services/service-storage/src/local-storage-adapter.ts:192) 是单层 readdir:
const entries = await fs.readdir(dirPath);
for (const entry of entries) {
const fullKey = prefix ? `${prefix}/${entry}` : entry;
const stat = await fs.stat(this.resolvePath(fullKey));
results.push({ key: fullKey, size: stat.size, lastModified: stat.mtime });
}
- 嵌套的 key(
a/b/c)在 list('a') 下看不到 —— 只会看到 a/b;
- 子目录被
stat 成功后当成文件推进结果,于是 StorageFileInfo 里出现一个 size 是目录 inode 大小、根本 download 不了的条目。
S3StorageAdapter.list (s3-storage-adapter.ts:214) 是递归的(ListObjectsV2 按 Prefix 匹配整串 key),而且没有翻页:
const cmd = new s3.ListObjectsV2Command({ Bucket: this.bucket, Prefix: prefix });
const res = await client.send(cmd);
return (res.Contents ?? []).map(...);
IsTruncated / ContinuationToken 都没读,所以超过 1000 个对象时静默截断,调用方拿到的"全部文件"其实是前 1000 个,没有任何信号。
为什么标 finding 而不是缺陷
今天没有生产消费方:仓库里唯一的调用点是 SwappableStorageService.list 的透传(它自己还会在适配器没有 list 时 reject)。storage-routes.ts、REST、CLI 都不调。所以这是"声明了但没人走"的漂移,不是用户今天会撞到的 bug —— 严重性交给 PM 分诊,不由我判。
不过它的形状是仓库反复点名的那一类:一个契约方法,N 个方言。第一个真正需要"枚举一个前缀下所有对象"的功能(备份、孤儿清理、迁移校验)会在两种部署上得到两种结果,而且两边都不报错。#5172 就是差点成为那个功能的:原本想用 list(EMAIL_ATTACHMENT_KEY_PREFIX) 驱动回收,发现在本地适配器上连 sys_email/attachments/<rowId>/<NNN> 都看不见(层级差一级),遂改道。
可能的处置(留给维护者)
- 让两边对齐到递归 + 翻页:本地适配器改
readdir(..., { recursive: true }) 并跳过目录项,S3 适配器补 ContinuationToken 循环;list 从 optional 收紧,并给 packages/spec/src/data/*-conformance.ts 那样的适配器一致性用例(嵌套 key、目录项、>1000 个对象各一例)。
- 或者按 ADR-0049 enforce-or-remove 把
list 摘掉:没有消费方、没有一致性用例、两种实现 —— 这正是"声明面大于实现面"的候选。摘掉后如果哪天真的需要枚举,再按需求把它设计成带分页游标的形状(list(prefix, { cursor, limit })),而不是继承一个不能翻页的签名。
倾向 1 还是 2 取决于是否已经有需要枚举 storage 的路线图;两者都比现状好,现状是"声明了一个在两种部署上行为不同、且都会静默给出不完整答案的方法"。
发现于 #5172(邮件大附件走 storage)的实现过程中 —— 本单不修,只记录。#5172 因为这条差异放弃了「列举 storage 前缀来回收内容」的方案,改为队列延迟任务驱动,所以本单不阻塞任何东西。
事实
IStorageService.list(prefix)的契约文字是 "List files in a directory/prefix"(packages/spec/src/contracts/storage-service.ts)。两个自带适配器对同一个调用给出的答案在语义上不同:LocalStorageAdapter.list(packages/services/service-storage/src/local-storage-adapter.ts:192) 是单层readdir:a/b/c)在list('a')下看不到 —— 只会看到a/b;stat成功后当成文件推进结果,于是StorageFileInfo里出现一个size是目录 inode 大小、根本download不了的条目。S3StorageAdapter.list(s3-storage-adapter.ts:214) 是递归的(ListObjectsV2按Prefix匹配整串 key),而且没有翻页:IsTruncated/ContinuationToken都没读,所以超过 1000 个对象时静默截断,调用方拿到的"全部文件"其实是前 1000 个,没有任何信号。为什么标
finding而不是缺陷今天没有生产消费方:仓库里唯一的调用点是
SwappableStorageService.list的透传(它自己还会在适配器没有list时 reject)。storage-routes.ts、REST、CLI 都不调。所以这是"声明了但没人走"的漂移,不是用户今天会撞到的 bug —— 严重性交给 PM 分诊,不由我判。不过它的形状是仓库反复点名的那一类:一个契约方法,N 个方言。第一个真正需要"枚举一个前缀下所有对象"的功能(备份、孤儿清理、迁移校验)会在两种部署上得到两种结果,而且两边都不报错。#5172 就是差点成为那个功能的:原本想用
list(EMAIL_ATTACHMENT_KEY_PREFIX)驱动回收,发现在本地适配器上连sys_email/attachments/<rowId>/<NNN>都看不见(层级差一级),遂改道。可能的处置(留给维护者)
readdir(..., { recursive: true })并跳过目录项,S3 适配器补ContinuationToken循环;list从 optional 收紧,并给packages/spec/src/data/*-conformance.ts那样的适配器一致性用例(嵌套 key、目录项、>1000 个对象各一例)。list摘掉:没有消费方、没有一致性用例、两种实现 —— 这正是"声明面大于实现面"的候选。摘掉后如果哪天真的需要枚举,再按需求把它设计成带分页游标的形状(list(prefix, { cursor, limit })),而不是继承一个不能翻页的签名。倾向 1 还是 2 取决于是否已经有需要枚举 storage 的路线图;两者都比现状好,现状是"声明了一个在两种部署上行为不同、且都会静默给出不完整答案的方法"。