Repository navigation
fix(store/kv): binding list 兼容 Cloudflare 语义并收敛 list 配额消耗 - #127
Conversation
…am#125) kv 驱动 binding 模式的 list() 只按 EdgeOne 语义读取(item.key / page.complete),而 Cloudflare Workers KV 返回 item.name / list_complete + 服务端 cursor。在 Cloudflare 上 keys 恒为空、 complete 恒 false,cursor 被赋值为 "",每轮以同一游标重复拉取同一页 直到 guard 上限:单次 list() 消耗上千次真实 KV list 请求,打满免费版 1000 次/天的 list 配额,随后 list 抛错 → saveDb 抛异常 → 初始化 500(issue OpenListTeam#125)。 修复: - list() 逐页识别两种平台形态:名称字段 name/key、末页判定 list_complete/complete、游标优先回传服务端 cursor(EdgeOne 回退为 本页最后一个 key);新增重复页检测与「游标无法推进即中止」防护, 未知形态最多多拉一页,绝不再空转。 - health() binding 模式增加一次 list 探测(prefix 不命中真实键,恰好 1 次操作):此前只探测 get,list 配额耗尽/接口异常的环境会被 /env_check 误报为「存储就绪」。health 仅在手动诊断路径被调用, 不放大每请求配额消耗。 - key 格式 load/save 各自的逐表 list(6 张表 = 6 次)收敛为 1 次全局 list 后按表前缀分组,结果与旧实现一致;免费版可承受的读写轮次 提升约 6 倍。 新增回归测试 5 组:Cloudflare 两页游标分页、EdgeOne 末 key 游标、 未知形态防空转、health list 探测(配额耗尽报不可用)、key 格式 load/save 各恰 1 次 list。store + kv 测试 17/17 通过。 Fixes OpenListTeam#125 Co-Authored-By: MiMo (OpenCode) <noreply@opencode.ai>
同一问题已由 PR OpenListTeam#127 (fix/125-kv-binding-list) 以更完整的方式修复:除 binding list 兼容 Cloudflare 语义外,还收敛了 format/key.ts 的 list 配额消耗,并加了 重复页检测。为避免两个 PR 重复修改 driver/kv.ts 并互相冲突,这里把该修复从本 PR 移除,交由 OpenListTeam#127 负责。 本 PR 因此只专注缓存功能。注意:在 OpenListTeam#127 合入之前,Cloudflare Workers 上缓存的 统计(GET /api/admin/cache/status)与清空(POST /api/admin/cache/clear、 POST /api/admin/storage/refresh)仍受该既有缺陷影响 —— 键能正常写入与命中, 但列举不到,属于既有问题,非本 PR 引入。 This reverts commit fb96e65.
) * feat(cache): add file-tree and download-link caches (DB by default) Implement the two-level cache from the OpenList.ts reference: - File-tree cache (`ft`): directory listings are served from cache, keyed by storage + virtual path. Empty directories are cached too, so repeated browsing no longer hits the remote drive every time. - Download-link cache (`ln`): the raw_url returned by the driver is reused instead of being re-signed/re-exchanged on every download, which is usually the most expensive and most rate-limited step. Caching is DB-only by default. `CACHE_DRIVER=db` (the default) reuses the backend resolved by `getStorageBackend()`, i.e. the same one as `DB_DRIVER`, so zero configuration means "cache in the database". Dedicated KV / Blob / cfkv / do / memory backends must be opted into explicitly via `CACHE_DRIVER` (e.g. `db,kv`, `kv`, `blob`) and are never auto-detected or auto-substituted, matching the existing DB_DRIVER rule that an explicitly configured driver never falls back. Safety boundary: only driver-layer results are cached (raw FileItem lists and raw links). Permissions, meta passwords, hide rules and signatures are still computed per request in server/fs.ts and server/raw.ts, so a cache hit cannot leak privileges. Any cache error degrades to a no-op and never breaks the request. Invalidation: writes (mkdir/rename/remove/move/copy/put) invalidate the affected path plus its parent directory for the file tree, and the path itself for links. Storage create/update/enable/disable/delete clears that storage's cache entirely. New environment variables (all optional): CACHE_ENABLED, CACHE_DRIVER, CACHE_FILE_TREE, CACHE_DOWNLOAD_LINK, CACHE_TTL, CACHE_LINK_TTL, CACHE_EXCLUDE_DRIVERS, CACHE_PREFIX. New admin endpoints: GET /cache/status, POST /cache/clear, POST /storage/refresh, POST /storage/refresh_one. * chore(git): ignore .workbuddy-ai/ local assistant data The local assistant keeps its project memory under .workbuddy-ai/, which must never be committed. Ignore it next to the existing .codebuddy entry. * Delete docs/pr-cache.md * fix(cache): 缓存键单射编码、补上传失效、前缀校验、链接缓存默认关闭 - encodeCachePathSegment 改为单射编码:转义前缀 x 本身转义为 x78, 消除 /a/b 与 /ax2fb 键碰撞(评审实测复现的跨目录串列表/直链问题); 超长路径折叠标记用 xg + 双 FNV-1a,xg 不可能出现在普通编码结果中, 折叠键与普通键不会互相碰撞;补参数化「任意不同路径键必不相等」回归 - 补缓存失效:/fs/upload/complete、/fs/multipart/complete、/fs/other、 /fs/get_direct_upload_info(含 other 回退分支)写入/签发直传 URL 后 调用 invalidatePaths,避免「上传成功但刷新看不到」 - CACHE_PREFIX 字符集校验:仅允许 [A-Za-z0-9_](KV 键约束),非法回退 默认值并告警,防止与业务键空间 / 整表 DELETE 冲突 - CACHE_DOWNLOAD_LINK 默认改为 false(维护者建议):部分网盘直链 TTL 极短,缓存复用易把失效直链发给用户;CACHE_LINK_TTL 保持独立选项, 同步 .env.example / .dev.vars.example / wrangler.jsonc / README * fix(fs): 请求级存储上下文始终传递 env,修复缓存静默换后端 getStorageRequestContext() 原先在缺少 executionCtx.waitUntil 时直接返回 undefined,把 env 一并丢掉。缓存(CACHE_DRIVER=db)与 singleflight (SINGLEFLIGHT=db)都靠它解析后端:拿不到 env 时 getStorageBackend(undefined) 会解析成内存驱动,而业务数据仍走全局 env ctx 的持久化后端 —— 缓存与数据库 被静默拆到两个后端。 表现为「列表看似命中缓存(内容陈旧),但持久层里一条缓存键都没有、 /cache/status 计数恒为 0、/cache/clear 永远 removed=0」,排查成本很高。 真正依赖 executionCtx 的只有 waitUntil,缺失时降级为同步等待 (scheduleStoragePersistence 已支持该路径),业务语义不变;另加一条 warn-once 提示该降级,避免同类问题再次静默。 新增回归测试 src/backend/server/storage_request_context.test.ts:刻意不传 app.request() 的 executionCtx,断言缓存键必须落到 CACHE_DRIVER=db 指向的 真实后端(修复前该用例失败)。 * fix(kv): list() 同时支持 Cloudflare 与 EdgeOne 的分页语义 在真实 Cloudflare Workers 上跑起来才暴露:binding 模式的 list() 只按 EdgeOne 语义解析(keys[].key + page.complete),而 Cloudflare KV 返回的是 keys[].name + list_complete + cursor ⇒ 一条都取不到,且首轮就被判为 complete,恒返回 []。 缓存模块的 cacheListKeys() 依赖本方法,于是「缓存统计恒为 0、 清空缓存 / 刷新文件树永远 removed=0」在 Cloudflare Workers 上静默失效 —— 而键其实早已写进 KV(实测:列目录 271ms → 命中缓存 36ms,KV 中确实存在 openlist_cache_ft_* 键)。 改为两套语义都支持:键名取 item.key ?? item.name;翻页终止看 list_complete || complete;下一页游标优先用 page.cursor,其次取本页最后一个 键;游标无进展即退出,避免语义不符时死循环;并用 Set 去重。 新增 src/backend/internal/model/store/driver/kv_list.test.ts,覆盖两套语义、 cursor 翻页、空结果、游标不前进时的终止性,以及无绑定时不静默返回 []。 * Revert "fix(kv): list() 同时支持 Cloudflare 与 EdgeOne 的分页语义" 同一问题已由 PR #127 (fix/125-kv-binding-list) 以更完整的方式修复:除 binding list 兼容 Cloudflare 语义外,还收敛了 format/key.ts 的 list 配额消耗,并加了 重复页检测。为避免两个 PR 重复修改 driver/kv.ts 并互相冲突,这里把该修复从本 PR 移除,交由 #127 负责。 本 PR 因此只专注缓存功能。注意:在 #127 合入之前,Cloudflare Workers 上缓存的 统计(GET /api/admin/cache/status)与清空(POST /api/admin/cache/clear、 POST /api/admin/storage/refresh)仍受该既有缺陷影响 —— 键能正常写入与命中, 但列举不到,属于既有问题,非本 PR 引入。 This reverts commit fb96e65.
pikachuren
left a comment
There was a problem hiding this comment.
🙏 感谢 @wumingzhinu 提交!
🤖 AI 自动审核声明:本评审报告由 AI 自动生成,当前使用 Claude Sonnet 4 模型进行分析。
🎯 结论
✅ Approve — 修复准确且防护充分,CI 全绿,配额收敛设计合理
📖 概要
fix(store/kv): binding list 兼容 Cloudflare 语义并收敛 list 配额消耗 · Fixes #125
核心改动:kv.ts list() 双语义适配 + key.ts 单次全局 list 代替逐表 list + 防空转保护 + health list 探测
🧭 整体方案
问题根因:旧 kv.ts binding 模式 list() 只按 EdgeOne 语义读取 item.key/page.complete,在 Cloudflare 上两字段恒为 undefined(CF 为 item.name/page.list_complete),导致 keys 恒空、complete 恒 false、cursor 恒为空字符串 → 每轮重复拉同一页直到 guard < 1000 上限,单次 list() 消耗约 1000 次真实 KV list 请求,打满 CF 免费版 1000 次/天配额;key 格式 load/save 对 6 张表逐表 list(各 6 次)加剧消耗。
修复路线:① 逐页识别两种平台形态(name/key + list_complete/complete + cursor 回传/回退),加重复页检测与"游标无法推进即中止"防护(硬断空转,绝不再烧配额);② key 格式改为一次空前缀全局 list 后按表前缀分组(load/save 各从 6 次降为 1 次,个人免费版可持续);③ health binding 模式增加 1 次 list 探测(CF 免费版 list 配额耗尽时原先 /env_check 仍报就绪,误导排查)。
方案合理性:✅ 双语义适配与 cfkv.ts/functions/kv-list 既有正确实现对齐,修复路径保守;key 格式全局 list 单次分组与逐表 list 结果逐字节一致(非表前缀键如 openlist_config 被忽略,行为不变);配额收敛约 6 倍使免费版 load+save 从约 167 轮/天提升至约 500 轮/天(个人站足够);防空转双重保护(空页终止 + 重复页检测 + 游标推进失败立即中止)是对免费版配额的硬兜底,任何未知/异常形态最多多拉 1 页。
📊 变更统计
4 个文件(+269/-19 行) | 功能 ⭐⭐⭐⭐⭐ | 最小改动 ⭐⭐⭐⭐ | 前向兼容 ⭐⭐⭐⭐⭐ | 方案设计 ⭐⭐⭐⭐⭐
🚨 关键问题
无重大问题(P0/P1 均无)
P2(可选优化):
- 💡
kv.ts:475识别出未知形态后的console.warn日志可补充平台诊断信息(如page的实际结构快照),便于社区报告边缘平台语义变种 - 💡
key.ts:28-29groupKeysByTable的 JSDoc 可补充"结果与逐表 list 一致:非表前缀键如openlist_config被忽略,与旧实现行为保持一致"(明确不属于破坏性变更)
📂 逐文件分析
kv.test.ts(新增 143 行)
改动意图:回归覆盖 #125 的两种平台语义与防空转保护
测试覆盖:4 组用例精准命中根因与修复边界:
- Cloudflare 语义(
name/list_complete/cursor原样回传),断言恰 2 次 list 调用、游标正确传递CUR1 - EdgeOne 语义(
key/complete、游标回退本页末 key) - 防空转保护(无
complete/cursor的未知形态,恰 2 次调用即终止 —— 旧实现会烧到 1000 次) - health list 探测(抛错 →
available:false;健康时恰 1 次、不用空前缀)
实现质量:✅ 桩设计清晰(createCfBinding构造 Cloudflare 形态、记录参数),断言精确(调用次数 + 游标传递),覆盖面完整(两种语义 + 防空转边界 + health)
kv.ts(+66/-12 行)
改动意图:① list() 逐页识别两种平台形态;② 防空转三重保护(空页、重复页、游标推进失败);③ health 增加 list 探测
核心逻辑:
- L475-478:名称字段优先
item.name(CF)、回退item.key(EdgeOne) - L487-491:重复页检测(
prevFirstKey比对本页首项,相同立即中止并 warn) - L496-503:末页判定优先
list_complete/complete,未知形态以"是否有下一页游标"为准 - L507-513:游标优先服务端下发的
cursor(CF)、回退本页末 key(EdgeOne);无游标推进时中止(#125 的配额爆炸路径) - L565:health 补
await kv.list({ prefix: "__health_check__", ... })(不命中真实键,恰 1 次操作;仅在手动诊断路径被调用,不在每请求路径上)
设计亮点:✅ 防空转三重保护层层兜底(Issue 报告者调研显示旧实现空转到 guard=1000 上限);双语义适配与cfkv.ts/functions/kv-list对齐(保守修复);seen用 Set 去重(防重复页误加入);health list 探测解决"配额耗尽但/env_check仍报就绪"的误导
key.ts(+32/-7 行)
改动意图:load()/save() 改为一次空前缀全局 list + 按表前缀分组(6 次 → 1 次)
核心逻辑:
- L28-60:新增
groupKeysByTable(allKeys: string[]),对全局 list 结果逐键检查是否匹配 6 张表前缀(users_/stor_/perm_/grou_/meta_/user_),不匹配则忽略 —— 与旧实现"逐表前缀 list 天然排除非表键"行为一致 - L71:
load()改为const grouped = groupKeysByTable(await driver.list("", env)),迭代时从grouped[table]取键 - L96-97:
save()清空旧数据时同理,一次全局 list 后分组删除
配额收敛效果:CF 免费版可承受的 load+save 轮次从约 167 轮/天提升至约 500 轮/天(个人站足够);对配额无限的平台也省 5 次往返
store.test.ts(+28 行)
改动意图:回归保护 key 格式的配额收敛承诺
测试设计:✅ 通过 counting wrapper 统计 list() 调用次数,断言 save 后 listCalls === 1、load 后 listCalls === 2(各 1 次全局 list),且 roundtrip 与旧实现一致
✅ 待处理清单
- [P2]
kv.ts:475console.warn 补充平台诊断信息(page 结构快照) - [P2]
key.ts:28JSDoc 明确"非表前缀键被忽略,行为与旧实现一致"
🎯 结论:✅ Approve — 修复准确、测试到位、配额收敛设计合理,CI 全绿,无阻塞问题
Summary / 摘要
修复 #125:Cloudflare Workers 上
DB_DRIVER=kv+DB_FORMAT=key初始化必 500、并打满免费版 list 配额。根因
kv.tsbinding 模式的list()只按 EdgeOne 语义 读取(代码注释亦如此声明):item.keykeynamekeys恒为空数组page.completecompletelist_completefalsepage.cursorundefined || ""=""存储非空时
complete恒 false 且游标恒为空 → 每轮以同一游标重复拉取同一页,直到guard < 1000上限 —— 单次list()消耗约 1000 次真实 KV list 请求。Cloudflare 免费版 list 配额 1000 次/天,初始化(save())一次即打满;随后list()抛错 →saveDb()异常 → Hono 裸 500。与报告者观察到的控制台配额提示、map格式立即恢复完全吻合。修复
list()逐页识别两种平台形态(与同目录cfkv.ts、functions/kv-list代理端对齐):name(Cloudflare)/key(EdgeOne);list_complete/complete优先,未知形态以「是否有下一页游标」为准;cursor(Cloudflare),EdgeOne 无该字段时回退为本页最后一个 key;health()binding 模式增加一次list探测(prefix: "__health_check__"不命中真实键,恰好 1 次操作):此前只探测get,因此 list 配额耗尽时/env_check仍报「存储就绪」(报告者的附带问题)。health仅在env_check/debug/admin等手动诊断路径被调用,不在每请求路径上,不会放大配额消耗。key格式配额收敛(报告者期望 2 的低风险路径):load()/save()原本对 6 张表逐表list(各 6 次),改为一次空前缀全局list后按表前缀分组,结果与旧实现逐字节一致(不属于任何表前缀的键如openlist_config与旧实现一样被排除在删除/装载之外)。免费版可承受的 load+save 轮次提升约 6 倍(约 500 轮/天,个人站足够)。变更文件
src/backend/internal/model/store/driver/kv.ts:list 双语义 + 防空转 + health list 探测src/backend/internal/model/store/format/key.ts:groupKeysByTable+ load/save 单次全局 listsrc/backend/internal/model/store/driver/kv.test.ts(新增):4 组 binding 语义回归src/backend/internal/model/store/store.test.ts:+1 组 list 次数回归This PR has breaking changes.
/ 此 PR 包含破坏性变更。
This PR changes public API, config, storage format, or migration behavior.
/ 此 PR 修改了公开 API、配置、存储格式或迁移行为。
(存储键名结构不变,无需迁移;list 消耗下降属行为修复。)
This PR requires corresponding changes in related repositories.
/ 此 PR 需要关联仓库同步修改。
Related Issues / 关联 Issue
Fixes #125
Testing / 测试
tsc --strict聚焦类型检查(4 个改动文件 + 传递依赖)通过tsx --test store.test.ts driver/kv.test.ts— 17/17 通过:- Cloudflare 语义:两页
name/list_complete/cursor分页,断言恰好 2 次 list 调用、游标回传CUR1;- EdgeOne 语义:
key/complete、游标回退本页末 key;- 防空转:无
complete/cursor的未知形态,恰好 2 次调用即终止(旧实现会烧到 1000 次上限);- health:list 抛错 →
available:false;健康时 list 探测恰 1 次且不用空前缀;- key 格式:
save/load各恰 1 次全局 list,roundtrip 与旧实现一致(既有 roundtrip 测试无回归)。prettier:本次新增代码合规;key.ts/store.test.ts上游既有格式违规保持原样未顺手重排map已恢复运行;合并后可请报告者切回key + kv验证初始化kv_proxy_loop.test.ts依赖hono(本地精简工具链未装全),由仓库 CI 的 Build and verify 覆盖Checklist / 检查清单
/ 我已阅读 CONTRIBUTING。
/ 我确认此贡献符合仓库许可证、贡献规范和行为准则。
prettierwhere applicable./ 我已按适用情况使用 prettier 格式化变更代码。
/ 我已在适用情况下请求相关维护者审查。
AI Disclosure / AI 使用声明
/ 此 PR 包含 AI 辅助内容。
Tools used / 使用工具:
Usage scope / 使用范围:
Code generation / 代码生成
Tests / 测试
Documentation / 文档
I have reviewed and validated all AI-assisted content included in this PR.
/ 我已审核并验证此 PR 中的所有 AI 辅助内容。
I have ensured that all AI-assisted commits include
Co-Authored-Byattribution./ 我已确保所有 AI 辅助提交都包含
Co-Authored-By归属信息。I can reproduce all AI-assisted content included in this PR without any AI tools.
/ 我可以在没有任何 AI 工具的情况下重现此 PR 中的所有 AI 辅助内容。(请提交人确认后勾选)