Skip to content

service-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

Description

@os-zhuang

发现于 #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) 是递归的(ListObjectsV2Prefix 匹配整串 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> 都看不见(层级差一级),遂改道。

可能的处置(留给维护者)

  1. 让两边对齐到递归 + 翻页:本地适配器改 readdir(..., { recursive: true }) 并跳过目录项,S3 适配器补 ContinuationToken 循环;list 从 optional 收紧,并给 packages/spec/src/data/*-conformance.ts 那样的适配器一致性用例(嵌套 key、目录项、>1000 个对象各一例)。
  2. 或者按 ADR-0049 enforce-or-remove 把 list 摘掉:没有消费方、没有一致性用例、两种实现 —— 这正是"声明面大于实现面"的候选。摘掉后如果哪天真的需要枚举,再按需求把它设计成带分页游标的形状(list(prefix, { cursor, limit })),而不是继承一个不能翻页的签名。

倾向 1 还是 2 取决于是否已经有需要枚举 storage 的路线图;两者都比现状好,现状是"声明了一个在两种部署上行为不同、且都会静默给出不完整答案的方法"。

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions