674e1b4fa2
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>
107 lines
4.7 KiB
TypeScript
107 lines
4.7 KiB
TypeScript
// 背景維護寫入的共用 D1 每日額度(Arcrun#85 D69,2026-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 checkUsage/embed.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) };
|
||
}
|