From 993c50cbe81ea0e9a8c8668b577001e55826ca9e Mon Sep 17 00:00:00 2001 From: wumingzhinu <78893617+wumingzhinu@users.noreply.github.com> Date: Tue, 13 Oct 2026 22:50:29 +0800 Subject: [PATCH] =?UTF-8?q?fix(store/kv):=20binding=20list=20=E5=85=BC?= =?UTF-8?q?=E5=AE=B9=20Cloudflare=20=E8=AF=AD=E4=B9=89=E5=B9=B6=E6=94=B6?= =?UTF-8?q?=E6=95=9B=20list=20=E9=85=8D=E9=A2=9D=E6=B6=88=E8=80=97=20(#125?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 #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 #125 Co-Authored-By: MiMo (OpenCode) --- .../internal/model/store/driver/kv.test.ts | 143 ++++++++++++++++++ src/backend/internal/model/store/driver/kv.ts | 76 ++++++++-- .../internal/model/store/format/key.ts | 42 ++++- .../internal/model/store/store.test.ts | 27 ++++ 4 files changed, 269 insertions(+), 19 deletions(-) create mode 100644 src/backend/internal/model/store/driver/kv.test.ts diff --git a/src/backend/internal/model/store/driver/kv.test.ts b/src/backend/internal/model/store/driver/kv.test.ts new file mode 100644 index 00000000..b36b84b5 --- /dev/null +++ b/src/backend/internal/model/store/driver/kv.test.ts @@ -0,0 +1,143 @@ +import assert from "node:assert/strict" +import test from "node:test" +import { kvDriver } from "./kv" + +/** + * issue #125 回归:binding 模式 list() 必须同时兼容两种平台语义。 + * + * Cloudflare Workers KV(KVNamespaceListResult): + * keys: [{ name }]、list_complete: boolean、cursor?: string(服务端下发) + * EdgeOne KV(官方 functions-kv 示例): + * keys: [{ key, ttl, meta }]、complete: boolean(游标取本页最后一个 key) + * + * 旧实现只按 EdgeOne 语义读 item.key / page.complete:在 Cloudflare 上 + * 两字段恒为 undefined,keys 恒为空、complete 恒 false,且 cursor 被赋值 + * 成 "",每轮重复拉取同一页直到 guard 上限 —— 单次 list() 消耗上千次真实 + * KV list 请求,打满免费版 1000 次/天配额。 + */ + +/** 构造 Cloudflare 形态的 KV binding 桩(按页返回,记录每次调用参数) */ +function createCfBinding( + pages: Array<{ keys: string[]; list_complete: boolean; cursor?: string }>, +) { + let i = 0 + const calls: any[] = [] + return { + calls, + env: { + KV: { + get: async () => null, + put: async () => {}, + delete: async () => {}, + list: async (opts: any) => { + calls.push(opts) + const page = pages[Math.min(i, pages.length - 1)] + i += 1 + return { + keys: page.keys.map((name) => ({ name })), + list_complete: page.list_complete, + cursor: page.cursor, + cacheStatus: null, + } + }, + }, + }, + } +} + +test("kv binding list follows Cloudflare semantics (name / list_complete / cursor)", async () => { + const cf = createCfBinding([ + { keys: ["users_1", "users_2"], list_complete: false, cursor: "CUR1" }, + { keys: ["users_3"], list_complete: true }, + ]) + + const keys = await kvDriver.list("users_", cf.env) + assert.deepEqual(keys, ["users_1", "users_2", "users_3"]) + // 两页 = 恰好两次真实 list 请求(回归:旧实现会重复拉同一页上千次) + assert.equal(cf.calls.length, 2) + assert.equal(cf.calls[0].cursor, "") + assert.equal(cf.calls[1].cursor, "CUR1") +}) + +test("kv binding list follows EdgeOne semantics (key / complete / last-key cursor)", async () => { + const pages: any[] = [ + { keys: [{ key: "stor_1", ttl: 0, meta: null }], complete: false }, + { keys: [{ key: "stor_2", ttl: 0, meta: null }], complete: true }, + ] + let i = 0 + const calls: any[] = [] + const env = { + KV: { + get: async () => null, + put: async () => {}, + delete: async () => {}, + list: async (opts: any) => { + calls.push(opts) + return pages[Math.min(i++, pages.length - 1)] + }, + }, + } + + const keys = await kvDriver.list("stor_", env) + assert.deepEqual(keys, ["stor_1", "stor_2"]) + assert.equal(calls.length, 2) + // EdgeOne 无服务端 cursor 字段:游标回退为本页最后一个 key + assert.equal(calls[1].cursor, "stor_1") +}) + +test("kv binding list stops when cursor cannot advance (quota-burn guard)", async () => { + let calls = 0 + const env = { + KV: { + get: async () => null, + put: async () => {}, + delete: async () => {}, + // 同一页永远返回:无 list_complete / complete / cursor 的未知形态, + // 或游标回传失效。绝不能空转到 guard 上限。 + list: async () => { + calls += 1 + return { keys: [{ name: "meta_1" }] } + }, + }, + } + + const keys = await kvDriver.list("meta_", env) + assert.deepEqual(keys, ["meta_1"]) + // 首页 + 一次重复页检测即终止,而不是 1000 次 + assert.equal(calls, 2) +}) + +test("kv health (binding) exercises list so exhausted quota is not reported ready", async () => { + // list 抛错(Cloudflare 免费版耗尽 list 配额时的行为)→ 必须报不可用 + const badEnv = { + KV: { + get: async () => null, + put: async () => {}, + delete: async () => {}, + list: async () => { + throw new Error("Too many list operations") + }, + }, + } + const bad = await kvDriver.health(badEnv) + assert.equal(bad.available, false) + assert.match(String(bad.error), /Too many list/) + + // 健康时:list 探测恰好消耗 1 次操作,且不用空前缀(不拉全量键) + let listCalls = 0 + const okEnv = { + KV: { + get: async () => null, + put: async () => {}, + delete: async () => {}, + list: async (opts: any) => { + listCalls += 1 + assert.notEqual(opts.prefix, "") + return { keys: [], list_complete: true } + }, + }, + } + const ok = await kvDriver.health(okEnv) + assert.equal(ok.available, true) + assert.equal(listCalls, 1) +}) diff --git a/src/backend/internal/model/store/driver/kv.ts b/src/backend/internal/model/store/driver/kv.ts index 0e5bc4b2..d9e68e8f 100644 --- a/src/backend/internal/model/store/driver/kv.ts +++ b/src/backend/internal/model/store/driver/kv.ts @@ -453,32 +453,79 @@ export const kvDriver: Driver = { // 模式1: Binding 模式 if (kv) { - // EdgeOne KV list() 语义(依据官方 functions-kv 示例): - // page.keys -> [{ key, ttl, meta }],page.complete 为 true 表示末页, - // 下一页 cursor 需手动取本页最后一个 key。 - const keys: string[] = [] + // 两种平台的 list() 返回形态不同,必须逐页识别(issue #125): + // Cloudflare Workers KV(KVNamespaceListResult): + // keys: [{ name, ... }]、list_complete: boolean、cursor?: string + // —— 游标由服务端下发,原样回传即可; + // EdgeOne KV(依据官方 functions-kv 示例): + // keys: [{ key, ttl, meta }]、complete: boolean + // —— 无 cursor 字段,下一页游标取本页最后一个 key。 + // 早期实现只按 EdgeOne 语义读 item.key / page.complete:在 Cloudflare + // 上两字段恒为 undefined —— keys 恒为空、complete 恒 false,且 cursor + // 被赋值为 ""(undefined || ""),每轮以同一游标重复拉取同一页直到 + // guard 上限,单次 list() 消耗上千次真实 KV list 请求,直接打满 + // Cloudflare 免费版 1000 次/天的 list 配额(issue #125)。 + const seen = new Set() let cursor = "" let complete = false let guard = 0 + let prevFirstKey: string | null = null while (!complete && guard < 1000) { guard += 1 - const page = await kv.list({ prefix, cursor, limit: 256 }) + const page: any = await kv.list({ prefix, cursor, limit: 256 }) const pageKeys = Array.isArray(page?.keys) ? page.keys : [] + // 空页视为末页(两种平台一致,也是未知形态的兜底) + if (pageKeys.length === 0) break + + // 名称字段:Cloudflare 为 name,EdgeOne 为 key + const names: string[] = [] for (const item of pageKeys) { - if (item?.key) keys.push(item.key) + const name = typeof item?.name === "string" ? item.name : item?.key + if (typeof name === "string" && name.length > 0) names.push(name) } - - if (pageKeys.length > 0) { - cursor = pageKeys[pageKeys.length - 1].key || "" + // 形态无法识别时中止,绝不能空转(空转 = 每轮一次真实 list 请求) + if (names.length === 0) break + + // 重复页检测:游标未推进(平台语义变化/回传失败)时立即终止, + // 与 functions/kv-list 代理端的同款防护一致 + if (prevFirstKey !== null && names[0] === prevFirstKey) { + console.warn("[KV] repeated page detected; stopping list pagination") + break + } + prevFirstKey = names[0] + + for (const name of names) seen.add(name) + + // 末页判定:Cloudflare list_complete / EdgeOne complete 优先; + // 两者都没有时以「是否有下一页游标」推进为准。 + const listDone = + page?.list_complete !== undefined + ? Boolean(page.list_complete) + : page?.complete !== undefined + ? Boolean(page.complete) + : false + if (listDone) { + complete = true + break } - complete = Boolean(page?.complete) || pageKeys.length === 0 + // 游标:优先服务端下发的 cursor(Cloudflare);EdgeOne 无该字段, + // 回退为本页最后一个 key。 + const lastKey = names[names.length - 1] + const nextCursor = + typeof page?.cursor === "string" && page.cursor.length > 0 + ? page.cursor + : lastKey + + // 无游标可推进时中止:继续循环只会拉取同一页(issue #125 的配额爆炸路径) + if (!nextCursor || nextCursor === cursor) break + cursor = nextCursor } - return keys + return [...seen] } // 模式2: HTTP 代理模式 @@ -510,6 +557,13 @@ export const kvDriver: Driver = { if (kv) { try { await kv.get("__health_check__") + // list 探测:list 是最稀缺的配额(Cloudflare 免费版仅 1000 次/天), + // 且 DB_FORMAT=key 的 load/save 依赖它。只探测 get 会让「list 配额 + // 耗尽 / list 接口异常」的环境误报为就绪(issue #125:env_check + // 全绿但初始化 500)。prefix 不会命中真实键,取回空页即 1 次操作; + // 且 health 只在手动诊断路径(env_check / debug / admin)被调用, + // 不在每请求路径上,不会放大配额消耗。 + await kv.list({ prefix: "__health_check__", cursor: "", limit: 256 }) return { driver: "kv", mode: "binding", diff --git a/src/backend/internal/model/store/format/key.ts b/src/backend/internal/model/store/format/key.ts index 432a3250..24ab0502 100644 --- a/src/backend/internal/model/store/format/key.ts +++ b/src/backend/internal/model/store/format/key.ts @@ -33,6 +33,33 @@ function fullKeyOf(table: string, id: string): string { return entityKeyOf(table, id) } +/** + * 将一次全局 list 的结果按表前缀分组。 + * + * key 格式的键名统一为 `_`(见 keycodec),因此一次空前缀 + * list 即可取回全部表的键,再按前缀分拣 —— 结果与逐表 list 完全一致, + * 但把 load/save 各自的 list 消耗从「每表 1 次(6 张表 = 6 次)」降为 + * 1 次。Cloudflare 免费版 list 配额仅 1000 次/天(issue #125), + * key + kv 组合下这是可持续性的关键;对配额无限的平台也无害(省 5 次 + * 往返)。 + * + * 命名空间中不属于任何表前缀的键(如 INIT_MARK、其他应用的键)会被 + * 忽略,与旧实现「逐表前缀 list 天然排除它们」的行为一致。 + */ +function groupKeysByTable(allKeys: string[]): Record { + const out = {} as Record + for (const table of TABLE_NAMES) out[table] = [] + for (const key of allKeys) { + for (const table of TABLE_NAMES) { + if (key.startsWith(tablePrefix(table))) { + out[table].push(key) + break + } + } + } + return out +} + export const keyFormat: FormatAdapter = { name: "key", @@ -41,14 +68,14 @@ export const keyFormat: FormatAdapter = { const mark = await driver.get(INIT_MARK, env) if (!mark) return null + // 一次全局 list 代替逐表 list(6 张表 = 6 次),见 groupKeysByTable + const grouped = groupKeysByTable(await driver.list("", env)) + const out: Record = {} for (const table of TABLE_NAMES) { - const prefix = tablePrefix(table) - const keys = await driver.list(prefix, env) - const records = [] - for (const key of keys) { + for (const key of grouped[table]) { const raw = await driver.get(key, env) if (raw) { try { @@ -66,11 +93,10 @@ export const keyFormat: FormatAdapter = { }, async save(data: any, driver: Driver, env?: any): Promise { - // 清空旧数据 + // 清空旧数据:一次全局 list 代替逐表 list(见 groupKeysByTable) + const grouped = groupKeysByTable(await driver.list("", env)) for (const table of TABLE_NAMES) { - const prefix = tablePrefix(table) - const keys = await driver.list(prefix, env) - for (const key of keys) { + for (const key of grouped[table]) { await driver.delete(key, env) } } diff --git a/src/backend/internal/model/store/store.test.ts b/src/backend/internal/model/store/store.test.ts index 0c05c9a4..9ee8cb9f 100644 --- a/src/backend/internal/model/store/store.test.ts +++ b/src/backend/internal/model/store/store.test.ts @@ -261,6 +261,33 @@ test("key format: roundtrip via mock KV driver (per-table keys)", async () => { assert.deepEqual(await keyFormat.load(driver), SAMPLE_DB) }) +test("key format: load/save issue one global list each (issue #125 quota)", async () => { + // 回归防护:key 格式有 6 张表,旧实现 load/save 各逐表 list(6 次)。 + // Cloudflare 免费版 list 配额仅 1000 次/天,必须收敛为每次 1 次全局 list。 + const base = createMockKvDriver() + let listCalls = 0 + const driver: Driver = { + name: "mock-kv-counting", + isAvailable: base.isAvailable, + init: base.init, + get: base.get, + put: base.put, + delete: base.delete, + list: async (prefix: string) => { + listCalls += 1 + return await base.list(prefix) + }, + health: base.health, + } + + assert.equal(await keyFormat.save(SAMPLE_DB, driver), true) + assert.equal(listCalls, 1) // save:清空旧数据只 1 次全局 list + + const loaded = await keyFormat.load(driver) + assert.deepEqual(loaded, SAMPLE_DB) + assert.equal(listCalls, 2) // load:再 1 次全局 list(行为与逐表一致) +}) + test("sql format: roundtrip via mock SQL driver (columnar)", async () => { const driver = createMockSqlDriver() assert.equal(await sqlFormat.save(SAMPLE_DB, driver), true)