Skip to content

fix(store/kv): binding list 兼容 Cloudflare 语义并收敛 list 配额消耗 - #127

Merged
PIKACHUIM merged 2 commits into
OpenListTeam:mainfrom
wumingzhinu:fix/125-kv-binding-list
Oct 16, 2026
Merged

PIKACHUIM merged 2 commits into
OpenListTeam:mainfrom
wumingzhinu:fix/125-kv-binding-list

Conversation

@wumingzhinu

Copy link
Copy Markdown
Contributor

Summary / 摘要

修复 #125:Cloudflare Workers 上 DB_DRIVER=kv + DB_FORMAT=key 初始化必 500、并打满免费版 list 配额。

根因

kv.ts binding 模式的 list() 只按 EdgeOne 语义 读取(代码注释亦如此声明):

代码读取 EdgeOne 实际 Cloudflare 实际 Cloudflare 上的结果
item.key key name keys 恒为空数组
page.complete complete list_complete 恒 false
游标 = 本页最后一个 key ✓ 应原样回传 page.cursor 被赋值为 undefined || "" = ""

存储非空时 complete 恒 false 且游标恒为空 → 每轮以同一游标重复拉取同一页,直到 guard < 1000 上限 —— 单次 list() 消耗约 1000 次真实 KV list 请求。Cloudflare 免费版 list 配额 1000 次/天,初始化(save())一次即打满;随后 list() 抛错 → saveDb() 异常 → Hono 裸 500。与报告者观察到的控制台配额提示、map 格式立即恢复完全吻合。

修复

  1. list() 逐页识别两种平台形态(与同目录 cfkv.ts、functions/kv-list 代理端对齐):
    • 名称字段:name(Cloudflare)/ key(EdgeOne);
    • 末页判定:list_complete / complete 优先,未知形态以「是否有下一页游标」为准;
    • 游标:优先原样回传服务端下发的 cursor(Cloudflare),EdgeOne 无该字段时回退为本页最后一个 key;
    • 新增重复页检测与**「游标无法推进即中止」**防护:任何未知/异常形态最多多拉一页,绝不再空转——这是对免费版配额的硬保护。
  2. health() binding 模式增加一次 list 探测(prefix: "__health_check__" 不命中真实键,恰好 1 次操作):此前只探测 get,因此 list 配额耗尽时 /env_check 仍报「存储就绪」(报告者的附带问题)。health 仅在 env_check / debug / admin 等手动诊断路径被调用,不在每请求路径上,不会放大配额消耗。
  3. key 格式配额收敛(报告者期望 2 的低风险路径):load()/save() 原本对 6 张表逐表 list(各 6 次),改为一次空前缀全局 list 后按表前缀分组,结果与旧实现逐字节一致(不属于任何表前缀的键如 openlist_config 与旧实现一样被排除在删除/装载之外)。免费版可承受的 load+save 轮次提升约 6 倍(约 500 轮/天,个人站足够)。

未采用「索引键」方案(报告者建议的另一路径):它要求每次 saveRecord/deleteRecord 做读-改-写,存在并发写竞争导致索引丢失的风险,且需要迁移逻辑,适合作为后续独立设计讨论,不适合夹带在本 bugfix 中。

变更文件

  • src/backend/internal/model/store/driver/kv.ts:list 双语义 + 防空转 + health list 探测

  • src/backend/internal/model/store/format/key.ts:groupKeysByTable + load/save 单次全局 list

  • src/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 上游既有格式违规保持原样未顺手重排
  • Cloudflare Workers 真实部署端到端验证 — 无 CF 账号环境,报告者 map 已恢复运行;合并后可请报告者切回 key + kv 验证初始化
  • kv_proxy_loop.test.ts 依赖 hono(本地精简工具链未装全),由仓库 CI 的 Build and verify 覆盖

Checklist / 检查清单

  • I have read CONTRIBUTING.
    / 我已阅读 CONTRIBUTING。
  • I confirm this contribution follows the repository license, contribution policy, and code of conduct.
    / 我确认此贡献符合仓库许可证、贡献规范和行为准则。
  • I have formatted the changed code with prettier where applicable.
    / 我已按适用情况使用 prettier 格式化变更代码。
  • I have requested review from relevant maintainers or code owners where applicable.
    / 我已在适用情况下请求相关维护者审查。

AI Disclosure / AI 使用声明

  • This PR includes AI-assisted content.
    / 此 PR 包含 AI 辅助内容。

Tools used / 使用工具:

  • Other (please specify) / 其他(请注明): MiMo (OpenCode)

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-By attribution.
    / 我已确保所有 AI 辅助提交都包含 Co-Authored-By 归属信息。

  • I can reproduce all AI-assisted content included in this PR without any AI tools.
    / 我可以在没有任何 AI 工具的情况下重现此 PR 中的所有 AI 辅助内容。(请提交人确认后勾选)

…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>
Wudarensheng added a commit to Wudarensheng/OpenList-Worker that referenced this pull request Oct 14, 2026
同一问题已由 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.
PIKACHUIM pushed a commit that referenced this pull request Oct 15, 2026
)

* 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 pikachuren left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🙏 感谢 @wumingzhinu 提交!
🤖 AI 自动审核声明:本评审报告由 AI 自动生成,当前使用 Claude Sonnet 4 模型进行分析。
⚠️ AI 分析结果仅供参考,可能存在误判或遗漏。如您发现任何问题或有不同意见,欢迎随时提出讨论和纠正。
⚠️ 重要提醒:即使 AI 评审认为代码质量良好且建议合并,最终是否合并仍需由项目维护者进行人工判定。项目维护者会综合考虑代码质量、项目规划、技术方向、团队资源等多方面因素做出是否合并的决策。

🎯 结论

✅ 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-29 groupKeysByTable 的 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:475 console.warn 补充平台诊断信息(page 结构快照)
  • [P2] key.ts:28 JSDoc 明确"非表前缀键被忽略,行为与旧实现一致"

🎯 结论:✅ Approve — 修复准确、测试到位、配额收敛设计合理,CI 全绿,无阻塞问题

PIKACHUIM
PIKACHUIM previously approved these changes Oct 16, 2026
@PIKACHUIM
PIKACHUIM merged commit 5f53f58 into OpenListTeam:main Oct 16, 2026
1 of 2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[BUG] Cloudflare KV binding 模式下 kvDriver.list() 沿用 EdgeOne 语义,字段名不匹配导致 key 格式下初始化 500 并耗尽 list 配额

3 participants