Files
Arcrun/kbdb/src/actions/maintenance-quota.ts
T
uncle6me-web 674e1b4fa2 fix(kbdb): D69 節流世代核對+標庫共用 D1 額度,補「挑哪一批」的統一 SelectionCriteria(Arcrun#85)
leo 逐行複核 fix/embed-backfill-d68 後點出的破口+二次裁決(票上全文見 Leo/Arcrun#85):

一、向量化優先序(今天寫的立刻/本週在跑的先跑/有查詢紀錄的庫優先/半年前慢慢跑)表達
不出來——策略要能從外面(工作流)指定,不能焊死在資料層。新增 embed.ts 的
`SelectionCriteria`(owner_id/source/library/since/until),backfillEmbeddings 與
reconcileEmbedGeneration 共用同一套形狀;「按庫」那一層現在有資料可用即可運作(見下)。

二、世代核對(reconcileEmbedGeneration)不打 AI 但逐筆寫 D1,47 萬筆候選 ≈ 4.7 倍 D1
100,000 rows/日免費額度,先前零保護。新增 actions/maintenance-quota.ts(單一 entries
列/日的共用計數器,精神同 execution-log.ts/embed.ts 既有慣例,不新增表)。

三、leo 二度裁決:「標庫」與「時間分層」其實是一件事,判定標準要從第一天同時容納兩者,
不能先做一半再回頭改。新增 actions/library-backfill.ts 的 backfillEntryLibraryTags——
呼叫端(ingest/daemon/Arcrun#87)決定要貼哪個庫、用 page_names(Gitea 原稿卡名精準
點名,leo 定案的正解)或 source_prefix/page_name_prefix 過渡 fallback 篩選候選,base
只負責安全、節流地寫入。owner_id 刻意必填(leo 點出「補錯 owner 等於白做」——實查卡片
掛在 owner_id=bfezv28v,換成 'leo' 查卻是空的)。

D69:reconcile 與標庫 backfill 共用同一顆「今天還剩多少 D1 寫入額度」計數器(不共用的話
其中一個會把另一個的閘繞過去);新增 POST /entries/backfill-library + GET .../status,
擴充 POST /embed/backfill 與 /embed/reconcile 吃 library/since/until 參數。

測試:92 → 39 個新增/擴充案例覆蓋 since/until/library 篩選、reconcile 額度真的擋
(含「拿掉 cap 會變紅」反向驗證)、標庫 backfill 冪等/owner_id 必填/page_names 精準比對、
以及兩個操作共用同一顆額度計數器的跨模組驗證(雙向:先 reconcile 耗盡再標庫、反之亦然)。
kbdb 全套 192 個測試綠燈,tsc --noEmit 除既有 auth.test.ts 舊缺陷外無新增錯誤。

紅線:未併 main、未部署、未動任何實例的 is_embedded 旗標(只在本地 SQLite 測試治具跑過)。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 18:39:55 +08:00

107 lines
4.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 背景維護寫入的共用 D1 每日額度(Arcrun#85 D692026-08-11)。
//
// 為什麼需要這個模組(不是每個 caller 各自算):
// D68 已經替「補算向量」的 Workers AI 呼叫量設了每日軟上限(embed.ts
// DEFAULT_BACKFILL_DAILY_LIMIT),但 leo 逐行複核後指出還有一個沒堵的洞——
// 世代核對(reconcileEmbedGeneration**不打 AI,卻一樣逐筆寫 D1**(補標 content_hash
// 或重置 is_embedded),47 萬筆候選 ≈ 4.7 倍 D1 免費層 100,000 rows written/日,而它
// 當時零保護。2026-08-11 leo 補了第二刀:**標庫(library backfill)也是同一種操作**
// ——多筆 D1 row write、不打 AI——若各自設一顆獨立計數器,做標庫時會把 reconcile
// 的閘繞過去(兩者加起來還是可能燒穿同一顆 D1)。
// ⇒ 兩者必須共用同一顆「今天 D1 背景維護寫入還剩多少」計數器,這裡就是那顆計數器。
//
// 儲存精神完全比照 execution-log.ts checkUsageembed.ts getBackfillUsageToday:單一
// entries 列/日(entry_type='kbdb_maintenance_usage'),upsert,不新增表(D38)。
//
// 額度怎麼選(不是拍腦袋,比照 execution-log.ts DEFAULT_DAILY_LIMIT 的既有算法):
// D1 免費層 100,000 rows written/日。execution_log 自設 20%20,000)留給知識卡;
// 本模組管的是「背景維護」(reconcile + 標庫 backfill,兩者都是低優先、非使用者
// 當下等待的操作),同樣自設 20%(20,000/日)——不是硬性 Cloudflare 限制,是不讓
// 背景維護把當天寫入額度和知識卡片的正常寫入/execution_log 搶光的自我節制,
// 可用 env.KBDB_MAINTENANCE_DAILY_WRITE_LIMIT 覆寫。
import type { Bindings } from '../types';
export const DEFAULT_MAINTENANCE_DAILY_WRITE_LIMIT = 20000;
export function maintenanceDailyLimit(env: Pick<Bindings, 'KBDB_MAINTENANCE_DAILY_WRITE_LIMIT'>): number {
const raw = env.KBDB_MAINTENANCE_DAILY_WRITE_LIMIT;
const n = raw ? parseInt(raw, 10) : NaN;
return Number.isFinite(n) && n > 0 ? n : DEFAULT_MAINTENANCE_DAILY_WRITE_LIMIT;
}
function utcDay(): string {
return new Date().toISOString().slice(0, 10);
}
/** 額度計數器 entries id(單一列/日;不分租戶——D1 rows-written 額度是實例級,非租戶級)。 */
function maintenanceUsageId(): string {
return `kbdb-maintenance-usage:${utcDay()}`;
}
/**
* 今天背景維護寫入已消耗的筆數。讀取失敗(含壞資料)誠實視為 0(caller 決定是否 fail-open
* 精神同 embed.ts getBackfillUsageToday)。
*/
export async function getMaintenanceUsageToday(db: D1Database): Promise<number> {
const row = await db
.prepare('SELECT metadata_json FROM entries WHERE id = ?')
.bind(maintenanceUsageId())
.first<{ metadata_json: string | null }>();
if (!row) return 0;
try {
const parsed = row.metadata_json ? (JSON.parse(row.metadata_json) as { writes?: number }) : {};
return Number(parsed.writes) || 0;
} catch {
return 0;
}
}
/** 今天背景維護額度用量 +by(upsert:讀現有列 → +by → UPDATE,不存在則 INSERT,冪等日切)。 */
export async function addMaintenanceUsage(db: D1Database, by: number): Promise<void> {
if (by <= 0) return;
const id = maintenanceUsageId();
const existing = await db
.prepare('SELECT metadata_json FROM entries WHERE id = ?')
.bind(id)
.first<{ metadata_json: string | null }>();
let prev = 0;
if (existing) {
try {
const parsed = existing.metadata_json ? (JSON.parse(existing.metadata_json) as { writes?: number }) : {};
prev = Number(parsed.writes) || 0;
} catch {
prev = 0;
}
await db
.prepare('UPDATE entries SET metadata_json = ?, updated_at = unixepoch() WHERE id = ?')
.bind(JSON.stringify({ day: utcDay(), writes: prev + by }), id)
.run();
} else {
await db
.prepare(`INSERT INTO entries (id, entry_type, metadata_json) VALUES (?, 'kbdb_maintenance_usage', ?)`)
.bind(id, JSON.stringify({ day: utcDay(), writes: by }))
.run();
}
}
export interface MaintenanceBudget {
limit: number;
used: number;
remaining: number;
}
/** 今天還剩多少背景維護 D1 寫入額度(reconcile/標庫 backfill 呼叫前先問這個)。 */
export async function maintenanceBudgetToday(
env: Pick<Bindings, 'KBDB_MAINTENANCE_DAILY_WRITE_LIMIT'>,
db: D1Database,
): Promise<MaintenanceBudget> {
const limit = maintenanceDailyLimit(env);
let used = 0;
try {
used = await getMaintenanceUsageToday(db);
} catch {
used = 0; // fail-open:計數器本身故障(含 D1 額度打滿)不該連背景維護都做不了
}
return { limit, used, remaining: Math.max(0, limit - used) };
}