Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
143 changes: 143 additions & 0 deletions src/backend/internal/model/store/driver/kv.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
76 changes: 65 additions & 11 deletions src/backend/internal/model/store/driver/kv.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>()
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 代理模式
Expand Down Expand Up @@ -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",
Expand Down
43 changes: 35 additions & 8 deletions src/backend/internal/model/store/format/key.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,33 @@ function fullKeyOf(table: string, id: string): string {
return entityKeyOf(table, id)
}

/**
* 将一次全局 list 的结果按表前缀分组。
*
* key 格式的键名统一为 `<table>_<id>`(见 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<TableName, string[]> {
const out = {} as Record<TableName, string[]>
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",

Expand All @@ -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<string, any> = {}

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 {
Expand Down Expand Up @@ -92,10 +119,10 @@ export const keyFormat: FormatAdapter = {
}

// 清理本快照中已不存在的键(删除的实体 / 改名的键)
// 一次全局 list 代替逐表 list(6 张表 = 6 次),见 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]) {
if (liveKeys.has(key)) continue
await driver.delete(key, env)
}
Expand All @@ -105,7 +132,7 @@ export const keyFormat: FormatAdapter = {
await driver.put(INIT_MARK, String(Date.now()), env)
return true
},

async getTable(table: string, driver: Driver, env?: any): Promise<any[]> {
const prefix = tablePrefix(table)
const keys = await driver.list(prefix, env)
Expand Down
27 changes: 27 additions & 0 deletions src/backend/internal/model/store/store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading