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
+55 -25
View File
@@ -1,24 +1,56 @@
/**
* Execution Logger — 執行結果寫入 ANALYTICS_KVfire-and-forget
* Execution Logger — 執行結果寫入 KBDBfire-and-forget
*
* 設計:每次 workflow 執行後,將統計數據寫入 ANALYTICS_KVkey = stats:{workflowId})。
* Phase 7 可升級為 POST 至 registry.arcrun.dev/analytics/record
* KV 額度事故修復(總管交辦,2026-08-07):舊版寫 ANALYTICS_KVWorkers KV),
* key = stats:{workflowId}:{timestamp}(註解寫「避免覆蓋」)⇒ 只增不減、永不覆蓋
* 封測者 Evan 處理約 690 個檔案,KV 免費層 write 上限 1,000/日被打爆(實測 1,070 write)。
*
* KBDB 鐵律(leo 2026-06-14):KBDBAPI-as-Wall,零 SQL——任何存取一律走 KBDB 的 HTTP API
* 不准直接對它的 D1 下 SQL。本檔因此**不直連任何 D1**,改 fire-and-forget POST
* `{KBDB_BASE_URL}/execution-log/record`(連法/認證頭完全比照既有 recordRecipeStats
* 慣例,見 webhook-handlers.ts;儲存/降級實作在 kbdb/src/actions/execution-log.ts)。
*
* leo 兩條判準:
* ① 執行紀錄是稽核資料 → 搬去 D1entries 表,rows written 100,000/日,額度是 KV 的 100 倍)。
* ② 不是 n8n、不靠 Execution 計費 → 少記:不留每節點輸入輸出,只留時間/workflow/verdict/
* duration/錯誤訊息/(可得的)目標;成功記最少,失敗多記一點(截斷長度不對稱,見 KBDB 端)。
*
* A2 自我降級(門檻與降級邏輯全在 KBDB 端,見 execution-log.ts):D1 額度仍與知識卡共用,
* 超過門檻 KBDB 會回報 mode='skip'/'log_failure_only',但**這件事對呼叫端透明**——
* 本函式不管 KBDB 決定寫或不寫,一律 fire-and-forget、永不 throwworkflow 執行不受影響。
*/
import type { Bindings, GraphNode } from '../types';
import { kbdbBase } from '../routes/kbdb-proxy';
export interface ExecutionVerdict {
workflow_id: string;
component_ids: string[];
verdict: 'success' | 'failed';
duration_ms: number;
message: string;
recorded_at: string;
target?: string;
}
/**
* 寫入執行結果至 ANALYTICS_KVfire-and-forget,不阻擋主流程)
* 由 c.executionCtx.waitUntil() 包裹呼叫
* 從觸發時的 trigger context 擷取這次處理的目標(page_name / path),供「哪些檔沒進去」
* 這種問題答得出來。只認這兩個 key(少記,不做窮舉式欄位挖掘/猜測)。
*/
function extractTarget(input?: Record<string, unknown>): string | undefined {
if (!input) return undefined;
const raw = input.page_name ?? input.path;
if (raw === undefined || raw === null) return undefined;
return typeof raw === 'string' ? raw : JSON.stringify(raw);
}
/**
* 寫入執行結果至 KBDBfire-and-forget,不阻擋主流程)。
* 由 c.executionCtx.waitUntil() 包裹呼叫。
*
* @param nodes 保留參數相容既有呼叫端簽名(原本用來算 component_ids);「不記每節點」
* 是本次修復的明確要求(少記),此參數現不使用。
* @param input 觸發時的 trigger context(可選)——只用來抓 page_name / path 當 target
* 不整包送出(少記:不留每節點輸入輸出,這裡也不例外)。
* @param apiKey 觸發者的租戶(可選,/execute 舊路徑無租戶概念)。
*/
export async function writeExecutionVerdict(
env: Bindings,
@@ -27,27 +59,25 @@ export async function writeExecutionVerdict(
verdict: 'success' | 'failed',
durationMs: number,
message: string,
input?: Record<string, unknown>,
apiKey?: string,
): Promise<void> {
void nodes; // 少記:不再從節點算 component_ids,保留參數只為呼叫端相容
try {
const componentIds = nodes
.filter(n => n.type === 'Component' && n.componentId)
.map(n => n.componentId!);
const record: ExecutionVerdict = {
workflow_id: workflowId,
component_ids: componentIds,
verdict,
duration_ms: durationMs,
message,
recorded_at: new Date().toISOString(),
};
// ANALYTICS_KV key = stats:{workflowId}:{timestamp}(避免覆蓋)
const key = `stats:${workflowId}:${Date.now()}`;
await env.ANALYTICS_KV.put(key, JSON.stringify(record), {
expirationTtl: 60 * 60 * 24 * 90, // 保留 90 天
const { base, headers } = kbdbBase(env);
await fetch(`${base}/execution-log/record`, {
method: 'POST',
headers,
body: JSON.stringify({
workflow_id: workflowId,
owner_id: apiKey ?? null,
verdict,
duration_ms: Math.max(0, Math.round(durationMs)),
message: message ?? '',
target: extractTarget(input) ?? null,
}),
});
} catch {
// fire-and-forget不拋錯,不影響主流程
// fire-and-forget任何錯誤(含 KBDB 端額度打滿、網路失敗)都吞掉、不影響主流程
}
}