fix(kv-quota): workflow 執行紀錄搬離 KV,改走 KBDB template 機制(A1/A2/A7)

事故:cypher-executor/src/actions/execution-logger.ts 舊版每跑完一次 workflow 就
ANALYTICS_KV.put() 一筆新 key(註解寫「避免覆蓋」)= 只增不減,封測者 Evan 處理約 690 個
檔案就把 KV 免費層 1,000 write/日打爆(實測 1,070 write),整個實例 429。

A1 少記:workflow 執行紀錄改走 KBDB template 機制(entries 表 entry_type='execution_log',
kbdb/migrations/0004_execution_log_template.sql 只 seed 一列 template 定義,零建表/改表)。
儲存精神比照既有 recipe_stat(kbdb/src/actions/recipe-stat.ts):template 只負責文件化,
實際一筆執行是 entries 表一列(1 次執行=1 次 D1 寫入,不走 entry_values 全展開)。欄位收斂:
時間/workflow/verdict/duration/錯誤訊息/(可得的)目標;成功記最少,失敗多記(訊息截斷長度
不對稱:200 vs 2000 字)。target 只認 trigger context 的 page_name/path,不整包存 input。

A2 自我降級:D1 額度仍與知識卡共用同一顆 100,000 rows/日,本模組自設 20% 軟上限(可用
EXECUTION_LOG_DAILY_WRITE_LIMIT 覆寫),超過 80% 降成只記失敗、超過 100% 完全停止記錄,
但 workflow 執行永遠照跑(cypher-executor 端 fire-and-forget 永不 throw)。

A7 讀取端:/workflows/:name/executions、/portal/data/workflows 的 last_execution、MCP
list_recent_executions 全部改打 KBDB HTTP API(GET /execution-log、/execution-log/latest),
取代原本的 ANALYTICS_KV list/get(免費層 list 也是 1,000/日)。

架構鐵律修正(本次施工中兩度被抓到走偏,過程留痕於 commit 訊息供後續參考):
- KBDB 三張表打天下(entries/templates/entry_values),永遠不加新 table——新資料類型
  一律用 template + entries,不建表、不 ALTER TABLE。
- KBDB = API-as-Wall,零 SQL:cypher-executor 端一律走 KBDB 的 HTTP API(連法比照既有
  recordRecipeStats/kbdbFetch 慣例),不直連任何 D1、不對 arcrun-kbdb 下任何原生 SQL。

順帶修復:kbdb/src/actions/entry-crud.ts listEntries 的 ORDER BY 補 `, rowid DESC` 二級
排序——entries.created_at 是 unixepoch() 秒級解析度,高頻寫入(execution_log 一秒內多筆)
常同秒,單靠 created_at DESC 不保證「最新一筆」正確,此為本次測試(latestExecutionLog)
發現的既有潛在缺陷,順手補上決定性排序,不改變任何既有查詢在 created_at 不同時的行為。

隔離:portal-data.ts INTERNAL_ENTRY_TYPES 加入 execution_log/execution_log_usage(與既有
value/workflow 同層級排除),避免用戶知識搜尋混進執行 log;本模組從不設 metadata_json.embed,
故永不進 Vectorize 語意搜尋索引。

不動:registry/src/actions/recordAnalytics.ts(零件市場統計,獨立 Worker、獨立 KV 命名空間、
不同資料模型,非本次事故根因所指範圍);cypher-executor/{wrangler.toml,kbdb/wrangler.toml}
未變動(repo 層級 deny 規則保護這兩個生產設定檔不被 AI 編輯)——ANALYTICS_KV binding
因此仍留在 wrangler.toml 宣告中但程式碼零讀寫點(見 PR 說明的完整 grep 佐證)。

KV 裡既有的 stats:* 舊資料不搬移(是統計不是真相源,維持原樣任其依 90 天 TTL 自然過期)。

測試:kbdb/tests/execution-log.test.ts(13 個,含零建表證明/少記/A2 降級/route)、
cypher-executor/tests/execution-logger.test.ts(payload 正確性/永不 throw)、
cypher-executor/tests/executions-route.test.ts(讀取端轉發)、portal-data.test.ts 對應區塊
改寫。kbdb 全測試 104/104 通過;cypher-executor 320 個測試中 9 個失敗為 main 既有(與本次
改動無關,改動前後 stash 對照確認)。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
uncle6me-web
2026-08-07 16:13:00 +08:00
parent 36a5630c63
commit 60688c3108
18 changed files with 818 additions and 84 deletions
+2 -2
View File
@@ -27,14 +27,14 @@ executeRouter.post('/execute', async (c) => {
const result = await executor.execute(graph as ExecutionGraph, context, c.env.EXEC_CONTEXT);
const duration_ms = Date.now() - start;
c.executionCtx.waitUntil(
writeExecutionVerdict(c.env, graph.id, graph.nodes, 'success', duration_ms, '執行完成')
writeExecutionVerdict(c.env, graph.id, graph.nodes, 'success', duration_ms, '執行完成', context, apiKey)
);
return c.json({ success: true, data: result.data, trace: result.trace, duration_ms });
} catch (err) {
const duration_ms = Date.now() - start;
const errMsg = err instanceof Error ? err.message : String(err);
c.executionCtx.waitUntil(
writeExecutionVerdict(c.env, graph.id, graph.nodes, 'failed', duration_ms, errMsg.slice(0, 100))
writeExecutionVerdict(c.env, graph.id, graph.nodes, 'failed', duration_ms, errMsg.slice(0, 100), context, apiKey)
);
if (err instanceof ExecutionError) {
const traceFormatted = err.trace.map(s => ({
+22 -28
View File
@@ -13,6 +13,7 @@
import { Hono } from 'hono';
import type { Bindings } from '../types';
import { listPausedRunsByApiKey } from '../lib/paused-runs';
import { kbdbBase } from './kbdb-proxy';
export const executionsRouter = new Hono<{ Bindings: Bindings }>();
@@ -132,11 +133,13 @@ executionsRouter.get('/executions/:task_id', async (c) => {
/**
* GET /workflows/:name/executions — 看某 workflow 最近 N 次執行 verdict
*
* 走 ANALYTICS_KV `stats:{workflowId}:*` prefix scan。
* KV 額度事故修復(2026-08-07):改打 KBDB `GET /execution-log`(原走 ANALYTICS_KV
* `stats:{workflowId}:*` prefix scan,免費層 list 也是 1,000/日,裝十幾支 workflow
* 的實例刷 90 次 portal 就見底)。KBDBAPI-as-Wallleo 2026-06-14):本路由**不直連
* 任何 D1**,一律走 HTTP,連法比照既有 kbdbBase() 慣例(kbdb-proxy.ts)。
*
* workflowId 等於 webhook nameexecution-logger 寫入時用 graph.id ?? name)。
*
* 限制:ANALYTICS_KV list 沒辦法依 timestamp 排序,只能拿 key 後段 timestamp 排。
* workflowId 等於 webhook nameexecution-logger 寫入時用 graph.id ?? name,與舊 KV
* key 同語意,沿用既有限制不在這次修復裡處理)。
*/
executionsRouter.get('/workflows/:name/executions', async (c) => {
const apiKey = c.req.header('X-Arcrun-API-Key');
@@ -164,30 +167,21 @@ executionsRouter.get('/workflows/:name/executions', async (c) => {
}, 404);
}
// 撈 stats:{name}:* 全 list(每個 key 含 timestamp 後綴)
const list = await c.env.ANALYTICS_KV.list({ prefix: `stats:${name}:`, limit: 1000 });
const { base, headers } = kbdbBase(c.env);
const params = new URLSearchParams({ workflow_id: name, owner_id: apiKey, limit: String(limit) });
const kbdbRes = await fetch(`${base}/execution-log?${params.toString()}`, { headers });
const kbdbBody = await kbdbRes.json().catch(() => null) as { success?: boolean; executions?: Array<{
verdict: string; duration_ms: number; message: string; target?: string; recorded_at: number;
}> } | null;
// 按 timestamp 降序(key suffix 是 unix ms
const sorted = [...list.keys].sort((a, b) => {
const ta = parseInt(a.name.split(':').pop() ?? '0', 10);
const tb = parseInt(b.name.split(':').pop() ?? '0', 10);
return tb - ta;
}).slice(0, limit);
const executions = [];
for (const key of sorted) {
const raw = await c.env.ANALYTICS_KV.get(key.name);
if (!raw) continue;
try {
const record = JSON.parse(raw);
executions.push({
timestamp: key.name.split(':').pop(),
...record,
});
} catch {
// skip
}
}
const executions = (kbdbRes.ok && kbdbBody?.success ? kbdbBody.executions ?? [] : []).map((r) => ({
timestamp: String(r.recorded_at),
workflow_id: name,
verdict: r.verdict,
duration_ms: r.duration_ms,
message: r.message ?? '',
...(r.target ? { target: r.target } : {}),
}));
return c.json({
ok: true,
@@ -197,7 +191,7 @@ executionsRouter.get('/workflows/:name/executions', async (c) => {
executions,
},
hints: executions.length === 0
? ['尚未有任何執行紀錄(或都過了 90d TTL。先 call /webhooks/named/:name/trigger 跑一次']
? ['尚未有任何執行紀錄。先 call /webhooks/named/:name/trigger 跑一次']
: [`最近 ${executions.length} 次。看到 verdict=failed 的,call /executions/:task_id 看 paused state 或繼續 debug`],
});
});
+17 -17
View File
@@ -129,7 +129,10 @@ function canReadLibrary(userLibraries: string[], library: string): boolean {
* metadata_json parse 失敗 → 視為保留(治標不誤殺;壞 metadata ≠ deprecated)。
* 純函式(單測用 export)。
*/
const INTERNAL_ENTRY_TYPES = new Set(['value', 'workflow']);
// execution_log/execution_log_usageKV 額度事故修復,2026-08-07):workflow 執行紀錄與其內部
// 用量計數器,entry_type 與既有 value/workflow 同層級的內部型別——一併排除,避免用戶搜尋知識時
// 混進執行 log(同層防線:本模組也從不設 metadata_json.embed=true,永不進語意搜尋索引)。
const INTERNAL_ENTRY_TYPES = new Set(['value', 'workflow', 'execution_log', 'execution_log_usage']);
export function filterDeprecatedEntries<T extends { metadata_json?: string | null; content?: string | null; entry_type?: string | null }>(
entries: T[],
@@ -560,23 +563,20 @@ portalDataRouter.get('/portal/data/workflows', (c) =>
/* 壞 record 誠實留空 */
}
}
// 最近一次執行:ANALYTICS_KV stats:{name}:{unix_ms}——key 後綴定長毫秒 timestamp
// 字典序=時間序,取最後一把 key 即最新(同 /workflows/:name/executions 的排序邏輯)。
// 最近一次執行:KV 額度事故修復(2026-08-07)改打 KBDB GET /execution-log/latest
// (原走 ANALYTICS_KV stats:{name}:* list,免費層 list 也是 1,000/日)。KBDB
// API-as-Wall:不直連 D1,走既有 kbdbFetch(本檔已在用,見上方 import)。
let last_execution: { timestamp: string; verdict?: string } | null = null;
const stats = await c.env.ANALYTICS_KV.list({ prefix: `stats:${name}:`, limit: 1000 });
if (stats.keys.length > 0) {
const latest = stats.keys.reduce((a, b) => (a.name > b.name ? a : b));
const ts = latest.name.split(':').pop() ?? '';
const rawStat = await c.env.ANALYTICS_KV.get(latest.name);
let verdict: string | undefined;
if (rawStat) {
try {
verdict = (JSON.parse(rawStat) as { verdict?: string }).verdict;
} catch {
/* 壞 record 誠實留空 */
}
}
last_execution = { timestamp: ts, verdict };
const execRes = await kbdbFetch(
c.env,
`/execution-log/latest?${new URLSearchParams({ workflow_id: name, owner_id: tenant }).toString()}`,
);
const execBody = await execRes.json().catch(() => null) as {
success?: boolean;
execution?: { verdict: string; recorded_at: number } | null;
} | null;
if (execRes.ok && execBody?.success && execBody.execution) {
last_execution = { timestamp: String(execBody.execution.recorded_at), verdict: execBody.execution.verdict };
}
return { name, description, created_at, cron_expr, last_execution };
}),
+3 -3
View File
@@ -312,7 +312,7 @@ async function triggerNamed(
c.executionCtx.waitUntil(
executeWebhookGraph(c.env, record.graph, triggerContext, name, apiKey, c.executionCtx, userAgent)
.then(result =>
writeExecutionVerdict(c.env, workflowId, nodes, result.success ? 'success' : 'failed', result.duration_ms, result.error ?? ''),
writeExecutionVerdict(c.env, workflowId, nodes, result.success ? 'success' : 'failed', result.duration_ms, result.error ?? '', triggerContext, apiKey),
),
);
return c.json({ accepted: true }, 202);
@@ -329,7 +329,7 @@ async function triggerNamed(
);
c.executionCtx.waitUntil(
writeExecutionVerdict(c.env, workflowId, nodes, result.success ? 'success' : 'failed', result.duration_ms, result.error ?? ''),
writeExecutionVerdict(c.env, workflowId, nodes, result.success ? 'success' : 'failed', result.duration_ms, result.error ?? '', triggerContext, apiKey),
);
return c.json(result, result.success ? 200 : 500);
@@ -401,7 +401,7 @@ async function queryNamed(
// 執行判決寫入不阻塞回應(waitUntil,與 /trigger 一致)。
c.executionCtx.waitUntil(
writeExecutionVerdict(c.env, workflowId, nodes, result.success ? 'success' : 'failed', result.duration_ms, result.error ?? ''),
writeExecutionVerdict(c.env, workflowId, nodes, result.success ? 'success' : 'failed', result.duration_ms, result.error ?? '', triggerContext, apiKey),
);
if (!result.success) {
+1 -1
View File
@@ -73,7 +73,7 @@ webhooksRouter.post('/webhooks/:token/trigger', async (c) => {
const workflowId = graph.id ?? token;
const nodes = Array.isArray(graph.nodes) ? (graph.nodes as import('../types').GraphNode[]) : [];
c.executionCtx.waitUntil(
writeExecutionVerdict(c.env, workflowId, nodes, result.success ? 'success' : 'failed', result.duration_ms, result.error ?? ''),
writeExecutionVerdict(c.env, workflowId, nodes, result.success ? 'success' : 'failed', result.duration_ms, result.error ?? '', triggerContext, apiKey),
);
return c.json(result, result.success ? 200 : 500);