// 背景維護寫入的共用 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): 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 { 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 { 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, db: D1Database, ): Promise { 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) }; }