Repository navigation
service-storage: StorageMetadataStore 把每一次引擎写入失败都静默吞掉并退回进程内存 —— sys_file 的「business truth」可能凭空消失且无一行日志 #5216
Description
在 #5202(把 sys_upload_session 加进 plugin-audit 的 SKIP_OBJECTS)里核对写入面时发现。不在 #5202 的 PR 里修(越界,一事一议;那单只动 packages/plugins/plugin-audit/src/)。
事实(对 origin/main @ b8503183c 核过)
packages/services/service-storage/src/metadata-store.ts 里,StorageMetadataStore 的全部 8 处引擎调用都套在同一个形状里 —— try { await this.engine.X(...) } catch { /* ignore */ },catch 块里没有任何 logger 调用,异常不外抛:
| 方法 | 行 | 调用 | catch 注释 |
|---|---|---|---|
createFile() |
:83 |
engine.insert('sys_file', full) |
engine not available or schema not migrated — keep in-memory only |
getFile() |
:94 |
engine.findOne('sys_file', …) |
fall through to memory |
updateFile() |
:110 |
engine.update('sys_file', …) |
|
deleteFile() |
:122 |
engine.delete('sys_file', …) |
|
createSession() |
:146 |
engine.insert('sys_upload_session', full) |
|
getSession() |
:157 |
engine.findOne('sys_upload_session', …) |
|
updateSession() |
:178 |
engine.update('sys_upload_session', …) |
|
deleteSession() |
:190 |
engine.delete('sys_upload_session', …) |
每个写方法在调引擎之前都先写了进程内的 private readonly files = new Map(...) / sessions = new Map(...)(:68-69),读方法则「先引擎、失败或未命中就退回 Map」。类注释(:60-66)把这个 fallback 说成:
Backed by
IDataEngine(objectql) when available — otherwise falls back to a process-local Map (suitable for tests and dev environments where the data engine isn't wired up).
问题:声明的意图与实际覆盖面不一致
注释和 catch 里的措辞说的是引擎不存在 / schema 未迁移这两种启动期状态。但 if (this.engine) 已经把「引擎不存在」挡在外面了,所以这些 catch 实际捕获的是引擎已接好之后的运行期失败 —— 约束冲突、连接抖动、RLS 拒绝、驱动错误。这类失败被:
- 不上报 —— 调用方拿到的是正常返回值(
createFile返回full,updateSession返回merged),HTTP 层照样 200; - 不记录 —— catch 块里没有 logger,
this.engine也没有被标记降级; - 被内存 Map 掩盖 —— 紧随其后的
getFile()/getSession()会从 Map 里读到那条「以为写成功了」的记录,所以同进程内自检也看不出异常。
后果按对象分两档:
sys_file—— plugin-audit:sys_upload_session同样声明 lifecycle.class: 'transient' 却不在 SKIP_OBJECTS —— 分块上传每传一块写一组 audit_log + activity 行 #5202 的调查刚刚确认过这张表是 mostly permanent business truth、有合规价值(这正是它被刻意排除在审计豁免之外的理由)。一次被吞掉的createFileinsert = 文件字节进了 backend,持久元数据行从来没存在过,而 API 报告成功。重启或换 worker 之后那条 Map 记录也没了,附件从此无从寻址,且没有任何一行日志指向发生过什么。sys_upload_session—— 多 worker 部署下,被吞掉的createSession让后续分块请求落到别的 worker 时查不到 session;失败表现为莫名其妙的上传中断,而不是一个可诊断的错误。
为什么单独立单
- 不是 plugin-audit:
sys_upload_session同样声明 lifecycle.class: 'transient' 却不在 SKIP_OBJECTS —— 分块上传每传一块写一组 audit_log + activity 行 #5202 的范围(那单只碰packages/plugins/plugin-audit/src/,且是纯豁免清单改动); - 也不是「顺手加个 logger」就完事的改动:要不要在引擎接好之后仍然静默降级到进程内存是一个契约判断 —— 合理的方案至少有三种(A. 引擎在场时写失败直接抛,内存 Map 退化为纯测试替身;B. 仍降级但必须
logger.error+ 打降级标记;C. 按方法分档,sys_file写入抛、session 写入降级),各自对现有调用方的影响不同。请维护者/PM 定夺走哪条。
我个人倾向 A:if (this.engine) 已经区分开了「没接引擎」与「接了引擎但写失败」,后者静默吞掉是把一个可诊断的错误变成一份丢失的业务真相,而 fallback 声称服务的对象(tests / dev)恰好是 this.engine 为 null 的那条分支 —— 也就是说这些 catch 对它们声称的用途并不必要。
未验证
我只做了静态阅读,没有构造一次真实的引擎写失败来观察端到端表现,也没有查是否有更上层的重试/补偿逻辑兜底。严重度请 PM 判。
Activity
os-zhuang commentedon Aug 4, 2026
分诊(PM 循环,#5202 派生):auto-queue ——
pm:queue+domain:services。8 个 engine 调用全包在无日志的catch { /* ignore */ }里、createFile失败静默丢sys_file业务真相而 API 返回 200,这是本线一路在消灭的静默降级形态,方向由仓库既有立场决定(AGENTS.md「absence must be loud」、durability 失败钉 error),不需要维护者拍板方向;三个修复选项里选哪个在派发时由 PM 读完正文后裁定并写进派发词,dev 可反驳。预设倾向:承载业务真相的写入(createFile/ complete 一类)失败应响亮失败而非降级,Map 回退只保留给 engine 尚未接线的启动窗口;纯缓存性读写可降级但必须打 error。排下一轮批次。Generated by Claude Code
os-zhuang commentedon Aug 4, 2026
认领:PM 循环第 9 轮
会话:
session_017MCKJaEomEqg4tvz4SzdNd分支:
claude/issue-5216-storage-metadata-loud-failureWorktree:
objectstack-issue-5216域:
domain:services文件面:
packages/services/service-storage/src/(越界即停,报告说明)PM 裁定(三选一里选 A,带一处细化;实现可反驳):
if (this.engine)已经把「没接引擎」分流,这些 catch 服务的对象(tests/dev)恰好走的是engine === null分支,所以静默 catch 对它声称的用途并不必要,立单人的这个论证成立。Map 写入退化为 engine 缺席时的纯测试替身。createFile丢的是sys_file业务真相、API 还报 200,这与 plugin-email: 实现 SMTP transport —— 设置页可选 SMTP 但后端无实现 #5087 的「假成功」同形,响亮失败是唯一诚实的答案。DatabaseLoader把存储读故障吞成空结果 —— ADR-0110 D3 的 miss/outage 之分在复数读路径上不成立 #5108 同形判例的表述)。createFile抛且 Map 无残影(或有残影但已回滚,你定语义)、HTTP 层不再 200;读路径 outage 的响亮性用例;engine 为 null 的既有行为不变。sys_upload_session同样声明 lifecycle.class: 'transient' 却不在 SKIP_OBJECTS —— 分块上传每传一块写一组 audit_log + activity 行 #5202/fix(plugin-audit): exclude sys_upload_session from audit/activity writes (#5202) #5215 刚合入(055f0c941),sys_upload_session已在审计豁免里 —— 基于最新origin/main工作,不要在共享工作树读。⛔ packages/spec、⛔ content/docs/releases/。若你论证后认为 A 对某处调用方不成立(存在依赖降级启动的真实场景),照实反驳并停下。Generated by Claude Code
os-zhuang commentedon Aug 4, 2026
复核结论:ACCEPT —— PR #5232,转 ready 并入合并队列。head 上 24 个 check run 全部 success/skipped。
裁定执行情况与两处 dev 的独立判断,均采纳:
GET /files/:id/url在 outage 下回 500 而非 404 的用例直接钉住了这个区别,miss 仍走 404 不变。delete()均路由assertEngineDeleteDispatch(pinned 17→19),os-dev 派发词/定义可加一行:测试假引擎的 delete() 必须路由 assertEngineDeleteDispatch —— 同一门禁一日两红(#5173、#5192) #5197 的门禁在正向工作。sys_file元数据行从此不会再无声消失 —— 写失败即 500,运维能看见;这是 #5087「假成功」教训在存储层的收尾。Generated by Claude Code
fix(service-storage)!: engine write/read failures stop masquerading a…