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>
This commit is contained in:
uncle6me-web
2026-08-11 18:39:55 +08:00
parent 1d6dde4a01
commit 674e1b4fa2
8 changed files with 787 additions and 23 deletions
+91 -16
View File
@@ -12,6 +12,7 @@
// base 只認這個通用旗標 → base 維持對內容語意無知。
import type { Bindings, Entry } from './types';
import { maintenanceBudgetToday, addMaintenanceUsage } from './actions/maintenance-quota';
// ── 嵌入模型(Arcrun#59:模型應可配置+index 版本化,支援換代重刷)────────────────
//
@@ -259,6 +260,44 @@ async function addBackfillUsage(db: D1Database, by: number): Promise<void> {
}
}
// ── 「挑哪一批」可以從外面指定(Arcrun#852026-08-11 leo 二度裁決)───────────────
//
// leo 的優先序不是「一律新到舊」的單一佇列,是**分層**:今天寫的立刻/這週在跑的先跑/
// 有查詢紀錄的庫優先/半年前的慢慢跑。分層要能實作,前提是「這次補哪一批」要能從外面
// (工作流)指定,不能只靠資料層自己決定的固定排序——策略要住在 leo 打得開的地方
// (工作流頁),不是焊死在這裡看不見也改不動。
//
// 這裡不預先幫 caller 決定「四層怎麼切」(那是策略,屬於呼叫端/工作流,見 Arcrun#85
// D70 段落的意圖草案),只提供**同一套篩選形狀**讓任何一層都能表達:
// - sinceuntil:時間窗(unix secondscreated_at 半開區間 [since, until))——時間分層
// (①今天/②本週/④半年前)都是同一個 since/until 參數,差別只在呼叫端傳的值。
// - library:依 metadata_json.$.library 過濾——一旦資料身上有庫這個資訊(Arcrun#87),
// 「有查詢紀錄的庫優先」這層可以直接用同一個參數,不必再改介面形狀。
// 三個操作(backfillEmbeddingsreconcileEmbedGenerationbackfillEntryLibraryTags
// 見 actions/library-backfill.ts)共用這個形狀,這就是「判定標準只有一份」的意思——
// 不是先做時間、之後為了庫再回頭改介面。
export interface SelectionCriteria {
owner_id?: string;
source?: string;
library?: string; // 精確比對 metadata_json.$.library(未標記的舊資料一律歸 'general',同 embedOnWrite 慣例)
since?: number; // created_at >= sinceunix seconds
until?: number; // created_at < untilunix seconds
}
function selectionCriteriaPredicate(opts: SelectionCriteria): { conds: string[]; params: unknown[] } {
const conds: string[] = [];
const params: unknown[] = [];
if (opts.owner_id) { conds.push('owner_id = ?'); params.push(opts.owner_id); }
if (opts.source) { conds.push("json_extract(metadata_json, '$.source') = ?"); params.push(opts.source); }
if (opts.library) {
conds.push("COALESCE(NULLIF(json_extract(metadata_json, '$.library'), ''), 'general') = ?");
params.push(opts.library);
}
if (typeof opts.since === 'number') { conds.push('created_at >= ?'); params.push(opts.since); }
if (typeof opts.until === 'number') { conds.push('created_at < ?'); params.push(opts.until); }
return { conds, params };
}
export interface BackfillResult {
enabled: boolean; // 模組是否開(false → 什麼都沒做,caller 該誠實回錯,不假裝)。
processed: number; // 本次真的嵌進 Vectorize 並標 is_embedded=1 的筆數。
@@ -282,7 +321,7 @@ export interface BackfillResult {
*/
export async function backfillEmbeddings(
env: Bindings,
opts: { limit?: number; owner_id?: string; source?: string; reindex?: boolean; offset?: number } = {},
opts: SelectionCriteria & { limit?: number; reindex?: boolean; offset?: number } = {},
): Promise<BackfillResult> {
if (!embedEnabled(env)) {
return {
@@ -304,10 +343,12 @@ export async function backfillEmbeddings(
// 🔴 2026-08-05**已下架的一律不嵌**(leo:「理論上它的向量也要刪掉,就不會有殘影了吧?」)。
// 沒有這條,下架時清掉的向量會在下一次 backfill 又被嵌回來 ⇒ 殘影復活,
// 而且 `reindex=true` 那條路更嚴重(它連 is_embedded=1 的都重推)。
const conds = [basePredicate, "COALESCE(json_extract(metadata_json, '$.status'), '') != 'deprecated'"];
const params: unknown[] = [];
if (opts.owner_id) { conds.push('owner_id = ?'); params.push(opts.owner_id); }
if (opts.source) { conds.push("json_extract(metadata_json, '$.source') = ?"); params.push(opts.source); }
// 「挑哪一批」(Arcrun#85):owner_id/source/library/since/until 全部走同一套
// selectionCriteriaPredicate,讓呼叫端(工作流)能表達時間分層與庫分層,不必等
// base 幫忙決定;本函式不預設任何一層,caller 傳什麼就篩什麼。
const sel = selectionCriteriaPredicate(opts);
const conds = [basePredicate, "COALESCE(json_extract(metadata_json, '$.status'), '') != 'deprecated'", ...sel.conds];
const params: unknown[] = [...sel.params];
const where = conds.join(' AND ');
// D68:由新到舊——最可能被查到的最先補回來(見檔頭 DEFAULT_BACKFILL_DAILY_LIMIT 段的決策脈絡)。
@@ -414,10 +455,14 @@ export async function backfillStatus(
export interface ReconcileResult {
enabled: boolean;
checked: number; // 本批檢查筆數(is_embedded=1 且 content_hash 非現行世代的候選)。
checked: number; // 本批「真的核對+寫回」的筆數(受下方 D1 額度截斷後的量)。
confirmed_current: number; // 核對後確認已在現行 Vectorize index:只補標 content_hash,未打 AI。
reset_to_pending: number; // 核對後確認不在現行 index:重置 is_embedded=0,回到正常 backfill 佇列。
remaining: number; // 本次之後仍待核對的筆數(可重複呼叫直到 0)。
remaining: number; // 本次之後仍待核對的筆數(不受額度影響,可重複呼叫直到 0)。
scanned: number; // 本批掃到的候選筆數(受 limit 限制,額度截斷前)。
quota_limit: number; // 今日「背景維護 D1 寫入」額度上限(與標庫 backfill 共用,見 maintenance-quota.ts)。
quota_used_today: number; // 本次呼叫後,今日累積已消耗的背景維護寫入額度。
quota_exceeded: boolean; // 本批是否因額度不足被截斷(true=還有候選但今天不再寫 D1,等明天/調高上限)。
}
/**
@@ -439,32 +484,50 @@ export interface ReconcileResult {
* 交回正常 backfill 佇列(下一輪照樣受「新到舊」排序+每日額度上限保護,不特別優待)。
*
* 不消耗 Workers AI 額度:零 AI.run,只有一次 D1 掃描 + 一次 Vectorize.getByIds + D1 寫回。
*
* D69Arcrun#852026-08-11 leo 逐行複核找到的破口):**這一步雖不打 AI,但逐筆寫 D1**——
* 每個候選最多消耗一次 row write(補標 content_hash 或重置 is_embedded,兩條路互斥、恰好一次),
* 47 萬筆候選 ≈ 4.7 倍 D1 100,000 rows written/日免費額度。與標庫 backfill(同樣是多筆 D1
* write、不打 AI)共用 `actions/maintenance-quota.ts` 的同一顆每日計數器——不共用的話,
* 補標庫時會把這裡的閘繞過去(反之亦然)。額度用完 → 誠實截斷候選,不再寫 D1,等明天。
*
* 「挑哪一批」:owner_id/library/since/until 走 SelectionCriteria(同 backfillEmbeddings
* backfillEntryLibraryTags 共用的篩選形狀),讓時間分層/庫分層能從外面指定。
*/
export async function reconcileEmbedGeneration(
env: Bindings,
opts: { limit?: number; owner_id?: string } = {},
opts: Pick<SelectionCriteria, 'owner_id' | 'library' | 'since' | 'until'> & { limit?: number } = {},
): Promise<ReconcileResult> {
if (!embedEnabled(env)) return { enabled: false, checked: 0, confirmed_current: 0, reset_to_pending: 0, remaining: 0 };
if (!embedEnabled(env)) {
return {
enabled: false, checked: 0, confirmed_current: 0, reset_to_pending: 0, remaining: 0,
scanned: 0, quota_limit: 0, quota_used_today: 0, quota_exceeded: false,
};
}
const limit = Math.min(Math.max(opts.limit ?? 50, 1), 200);
const currentModel = embedModel(env);
const sel = selectionCriteriaPredicate(opts);
const conds = [
'is_embedded = 1',
'(content_hash IS NULL OR content_hash != ?)',
"COALESCE(json_extract(metadata_json, '$.status'), '') != 'deprecated'",
...sel.conds,
];
const params: unknown[] = [currentModel];
if (opts.owner_id) {
conds.push('owner_id = ?');
params.push(opts.owner_id);
}
const params: unknown[] = [currentModel, ...sel.params];
const where = conds.join(' AND ');
const res = await env.DB
.prepare(`SELECT id FROM entries WHERE ${where} ORDER BY created_at DESC LIMIT ?`)
.bind(...params, limit)
.all<{ id: string }>();
const ids = (res.results ?? []).map((r) => r.id);
const scannedIds = (res.results ?? []).map((r) => r.id);
const scanned = scannedIds.length;
// D69:額度截斷——每個候選最多 1 次 D1 write,直接照剩餘額度砍候選清單長度。
const budget = await maintenanceBudgetToday(env, env.DB);
const ids = scannedIds.slice(0, budget.remaining);
const quotaExceeded = scanned > ids.length;
const checked = ids.length;
let confirmed_current = 0;
@@ -497,7 +560,19 @@ export async function reconcileEmbedGeneration(
.prepare(`SELECT COUNT(*) as c FROM entries WHERE ${where}`)
.bind(...params)
.first<{ c: number }>();
return { enabled: true, checked, confirmed_current, reset_to_pending, remaining: remRow?.c ?? 0 };
const written = confirmed_current + reset_to_pending;
try {
await addMaintenanceUsage(env.DB, written);
} catch {
// fail-open:額度計數寫入失敗不影響已經完成的核對寫入(精神同 backfillEmbeddings 的
// addBackfillUsage 失敗處理——寧可下次呼叫少算一點用量,也不讓計數故障吞掉已做的工)。
}
return {
enabled: true, checked, confirmed_current, reset_to_pending, remaining: remRow?.c ?? 0,
scanned, quota_limit: budget.limit, quota_used_today: budget.used + written, quota_exceeded: quotaExceeded,
};
}
export interface SelfTestResult {