4ca23c256a
背景:08-07 事故修復(60688c3)已把執行紀錄從 KV 搬到 KBDB/D1(entries 表,
API-as-Wall),解掉「稽核資料放在會揮發、被額度打斷的地方」這個結構性錯誤,
也順帶把 Evan 撞到的 1,070 次寫入牆退到 D1 額度層級。但那次修復留了一個誠實
的缺口:MCP list_recent_executions 的說明文字寫著「無固定保留期」——保留期
可設定這件事還沒做。本次補上。
P7 規格(system-dev/docs/3-specs/pending-changes.md「P7」,leo 08-08 confirm):
執行紀錄是稽核資料,預設保留 90 天(3 個月)過期即清;租戶可自訂天數,也可
設「不刪除」(企業稽核,leo:「我願意花很多錢保存,不要刪除」)。
實作(kbdb/src/actions/execution-log.ts,牆內):
- getRetentionDays/setRetentionDays:沿用 execution_log_usage 的 upsert 慣例,
單一 entries 列/租戶(entry_type='execution_log_retention_config'),零建表。
- cleanupExpiredLogs:分兩段掃——有自訂天數的租戶各自 cutoff;其餘(含無租戶)
套預設 90 天,排除「不刪除」與已處理過的租戶。每次呼叫界限刪除量
(CLEANUP_BATCH_LIMIT=500),長期多次呼叫可逐步清完累積量。
路由(kbdb/src/routes/execution-log.ts):GET/PUT /execution-log/retention、
POST /execution-log/cleanup,沿用既有的 Bearer token 全域守衛(fail-closed)。
清理觸發(cypher-executor/src/scheduled.ts):不新增排程基礎設施(wrangler.toml
[triggers] 是受保護檔案)——搭 cypher-executor 既有的每分鐘 cron tick 便車,
固定 UTC 02:30 那一分鐘 fire-and-forget 打一次 KBDB 的 cleanup 端點,一天一次,
不是輪詢。
Portal 薄殼(cypher-executor/src/routes/portal.ts):GET/PUT
/portal/admin/execution-log-retention(role=admin 閘),讓本實例的租戶
(portalTenant)能實際設定保留天數,不只是 KBDB 內部端點。
測試(kbdb/tests/execution-log.test.ts):新增 27 個測試(含原有測試共 27 通過
於本檔),真 SQLite 驗證 cutoff 邏輯、自訂天數隔離、「不刪除」永不清、壞資料
容錯、混合租戶情境、路由層 400/200。測試治具需要「插入指定 created_at 的過期
紀錄」這個正式寫入路徑刻意不開放的能力,做成 kbdb/src/actions/execution-log.ts
內匯出的 testInsert*/testCount* 函式(牆內執行 SQL),測試檔本身零原生 SQL。
量測(不是推論):youlin(yuga3bse)實例上,redeploy 後對 graph_neighbors
webhook 發送 1,200 次併發請求(超過 Evan 實測失敗的 1,070 次)——全部 HTTP 200;
ANALYTICS_KV 的 key 數量在請求前後維持 663 不變,證明新寫入路徑完全不碰 KV,
Evan 撞到的那道牆的成因已被物理移除,不只是延後。
讀取端驗證(真呼叫,非 curl):透過綁定 yuga3bse 的 MCP 連線實際呼叫
arcrun_list_recent_executions(回傳含本次量測寫入的 D1 紀錄)與
arcrun_get_execution_trace(正確回 404 not_found,非崩潰);portal 前端
(https://arcrun-rag-ui.youlin-hsieh-dev.workers.dev/portal)瀏覽器實際載入,
無 console 錯誤、無異常紅色橫幅。
舊資料:KV 裡既有的 stats:* 沿用 60688c3 的既有決定——不搬移,任其依現有 90
天 TTL 自然過期(那是統計快取不是真相源);新的 D1 execution_log 保留政策只
管新資料,不回溯處理。
部署:cypher-executor + kbdb 已手動部署到 youlin(yuga3bse,AI 測試場,leo
08-08 令),未動 prod(uncle6)。本次僅程式碼行為變更、無新增/修改 D1 binding、
無新表——三個既有 D1 binding(CREDENTIALS_DB×2+kbdb DB)維持原樣,未新增第四個。
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
97 lines
4.2 KiB
TypeScript
97 lines
4.2 KiB
TypeScript
/**
|
||
* scheduled() handler — 對應 wrangler.toml [triggers].crons 觸發。
|
||
*
|
||
* 流程:
|
||
* 1. 單次 get cron index(cron-idx:_all,集中存所有 cron workflow 的 cron_expr)
|
||
* 2. 在記憶體比對每筆 cron_expr 跟 event.scheduledTime(UTC 分鐘精度)
|
||
* 3. 匹配才去讀完整 workflow record({apiKey}:wf:{name})
|
||
* 4. 匹配 → executeWebhookGraph 跑(waitUntil 背景,不擋)
|
||
* 5. 每天固定一分鐘(UTC 02:30)順便叫 KBDB 清一批過期執行紀錄(P7 保留期,見下方 §5)
|
||
*
|
||
* 8.P0 止血(SDD §8.2):原本每分鐘 WEBHOOKS.list('cron-idx:') = 1440 list/日 爆 KV 上限,
|
||
* 改成單一固定 key 只 get 一次 → list 歸零。
|
||
*
|
||
* SDD: arcrun.md 三-A P1 #3 / kbdb-base §8.2
|
||
*/
|
||
|
||
import type { ExecutionContext, ScheduledController } from '@cloudflare/workers-types';
|
||
import type { Bindings } from './types';
|
||
import { cronMatch } from './lib/cron-match';
|
||
import { readCronIndex, parseCronEntryKey } from './lib/cron-index';
|
||
import { executeWebhookGraph } from './actions/webhook-handlers';
|
||
import { kbdbBase } from './routes/kbdb-proxy';
|
||
|
||
type StoredWorkflowRecord = {
|
||
graph: Record<string, unknown>;
|
||
cron_expr?: string;
|
||
// 其他欄位(id, name, created_at 等)忽略
|
||
};
|
||
|
||
export async function handleScheduled(
|
||
controller: ScheduledController,
|
||
env: Bindings,
|
||
ctx: ExecutionContext,
|
||
): Promise<void> {
|
||
const now = new Date(controller.scheduledTime);
|
||
console.log('[scheduled] tick', now.toISOString(), 'controller.cron=', controller.cron);
|
||
|
||
// 8.P0:單次 get 集中索引(取代每分鐘 list),主 workflow record 仍在 {apiKey}:wf:{name}
|
||
const index = await readCronIndex(env.WEBHOOKS);
|
||
const entries = Object.entries(index);
|
||
|
||
let triggered = 0;
|
||
for (const [entryKey, cronExpr] of entries) {
|
||
const parsed = parseCronEntryKey(entryKey);
|
||
if (!parsed) continue;
|
||
const { apiKey, name } = parsed;
|
||
|
||
if (!cronExpr) continue;
|
||
if (!cronMatch(cronExpr, now)) continue;
|
||
|
||
// 匹配才去讀完整 workflow record
|
||
const wfKey = `${apiKey}:wf:${name}`;
|
||
const wfRaw = await env.WEBHOOKS.get(wfKey, 'text');
|
||
if (!wfRaw) {
|
||
console.warn('[scheduled] cron-idx 對應 workflow 不存在', wfKey);
|
||
continue;
|
||
}
|
||
let record: StoredWorkflowRecord;
|
||
try { record = JSON.parse(wfRaw) as StoredWorkflowRecord; } catch { continue; }
|
||
triggered++;
|
||
|
||
console.log('[scheduled] trigger', name, 'apiKey=', apiKey.slice(0, 12) + '...', 'cron=', cronExpr);
|
||
// 把 apiKey 也放進 triggerContext,讓 workflow 內節點能用 {{api_key}}(跟 webhook trigger 慣例一致)
|
||
const triggerContext = {
|
||
api_key: apiKey,
|
||
_triggered_by: 'cron' as const,
|
||
_scheduled_at: now.toISOString(),
|
||
};
|
||
ctx.waitUntil(
|
||
executeWebhookGraph(env, record.graph, triggerContext, name, apiKey)
|
||
.then(
|
||
(r) => console.log('[scheduled] done', name, r.success, r.duration_ms + 'ms'),
|
||
(e) => console.error('[scheduled] fail', name, e),
|
||
),
|
||
);
|
||
}
|
||
console.log(`[scheduled] scanned ${entries.length} cron-idx entries, ${triggered} triggered`);
|
||
|
||
// §5 P7 保留期清理(2026-08-09):不新增排程基礎設施(wrangler.toml [triggers] 是受保護
|
||
// 檔案,AI 不可編輯——見 InkStoneCo 頂層 pending-changes.md P9 段 L1 權限閘),改「搭便車」:
|
||
// 這支 handler 本來就每分鐘醒一次(給上面的 cron workflow 用),挑固定一分鐘(UTC 02:30,
|
||
// 避開整點/半點常見的 cron 表達式擁擠時段)順手打一次 fire-and-forget 給 KBDB 的
|
||
// POST /execution-log/cleanup。頻率仍是「一天一次」,不是輪詢外部系統要狀態,是既有 tick
|
||
// 順手打理自己的表。呼叫失敗不影響上面的 cron workflow 觸發(各自 try/catch,互不拖累)。
|
||
if (now.getUTCHours() === 2 && now.getUTCMinutes() === 30) {
|
||
const { base, headers } = kbdbBase(env);
|
||
ctx.waitUntil(
|
||
fetch(`${base}/execution-log/cleanup`, { method: 'POST', headers })
|
||
.then(async (r) => {
|
||
const body = await r.json().catch(() => null);
|
||
console.log('[scheduled] execution-log cleanup', r.status, JSON.stringify(body));
|
||
})
|
||
.catch((e) => console.error('[scheduled] execution-log cleanup failed', e)),
|
||
);
|
||
}
|
||
}
|