merge: 補算向量的節奏、額度、世代核對+標庫共用同一顆閘(Arcrun#85/D68/D69)
總管逐筆審過 1d6dde4/674e1b4/37e13fc:全部落在 kbdb/,沒有新表(額度用量寄生在 既有 entries 表單一列,D38)、沒有動實例。標庫與時間分層共用一套 SelectionCriteria 與同一顆每日 D1 額度計數器——兩者若各記各的,其中一個會把另一個的閘繞過去。 實測:kbdb 全套 196 pass / 0 fail(含反向驗證:拿掉 cap 那個 case 會變紅)。 紅線仍在:這只是併進 main,還沒部署到任何實例。
This commit is contained in:
@@ -0,0 +1,168 @@
|
||||
// 標庫 backfill(Arcrun#85 二次裁決,2026-08-11;相關票 Arcrun#87「藏書地圖是空的」)。
|
||||
//
|
||||
// 背景:leo 把向量化優先序講成一句話後又補一刀:「它是兩件事?其實是一件——沒有庫就
|
||||
// 剩一個,但你把庫標好以後,原來的判定要修改對吧」。⇒ 判定標準(embed.ts 的
|
||||
// SelectionCriteria)必須從第一天就同時容納時間與庫,本檔提供「庫」這一半真正的資料。
|
||||
//
|
||||
// base 的 write path 早就支援(`createEntry` 的 `metadata_json.$.library`,t52:「庫由
|
||||
// ingest 蓋章決定」)——既有的搜尋/embed/deprecate-by-library 全都讀這個欄位,
|
||||
// **缺的不是機制,是既有資料沒被蓋章**(library-map.ts 檔頭 2026-07-19 對 prod 核實:
|
||||
// 既有 entries 的 metadata.library 是空的)。「源頭寫入就貼標」是呼叫端(ingest)的事,
|
||||
// base 這裡管不到、也不該猜(base 對內容語意無知的既有原則);triplet 的實際寫入更是
|
||||
// 在另一個 repo(見 kbdb/src/index.ts 檔頭「triplet (separate repo)」)。
|
||||
//
|
||||
// 這個模組只做「補存量」那一半,且刻意設計成呼叫端驅動:
|
||||
// - base 不猜「這筆該屬於哪個庫」——那是語意判斷。呼叫端給一個 target library 值
|
||||
// +一組篩選條件,base 只負責把符合條件、目前未標記的 entries 安全、節流地蓋上這個值。
|
||||
// - 篩選條件有兩種精度(2026-08-11 leo 定案的做法後補上):
|
||||
// ① 精準比對 `page_names`(IN 清單)——leo 定的正解:「有 2 份原稿,在 gitea 和我的
|
||||
// Mac……去 gitea 把每個庫有哪些的卡名列出,跑來遍歷應該就搞定了」。呼叫端(daemon/
|
||||
// #87)從 Gitea repo 列出卡名,逐批把「這些卡名屬於庫 X」精準地寫進來,不必猜。
|
||||
// ② `source_prefix`/`page_name_prefix` 前綴 fallback(同 library-map.ts
|
||||
// recomputeLibraryMap 的 source_prefix 精神,只是這裡是寫入不是聚合)——沒有精準
|
||||
// 清單時的過渡手段,精度不如①,兩者可並用(AND)縮小範圍。
|
||||
// - 冪等:已標記的 entries 不會再入選(WHERE 帶「library 為空」)。
|
||||
//
|
||||
// D69 節流:與 reconcileEmbedGeneration(embed.ts)共用 maintenance-quota.ts 的同一顆
|
||||
// 每日 D1 寫入計數器——兩者都是「多筆 D1 row write、不打 AI」的背景維護操作,不共用
|
||||
// 計數器的話,補存量時會把世代核對的閘繞過去(leo 2026-08-11 二次裁決原話:「每日上限
|
||||
// 這件事不只管向量化,也要管補標,否則做標庫時就會把補算的閘繞過去」)。
|
||||
import type { Bindings } from '../types';
|
||||
import { maintenanceBudgetToday, addMaintenanceUsage } from './maintenance-quota';
|
||||
|
||||
// IN 清單長度上限(避開 D1/SQLite bound-parameter 上限;一次點名這麼多張卡已經很夠用,
|
||||
// 呼叫端清單更長就自然分批呼叫,跟 limit 分頁是同一種節奏)。
|
||||
const MAX_PAGE_NAMES = 300;
|
||||
|
||||
export interface LibraryBackfillCriteria {
|
||||
owner_id?: string;
|
||||
entry_type?: string;
|
||||
page_names?: string[]; // 精準比對 page_name(IN 清單)——leo 定案的正解:從 Gitea repo
|
||||
// 列出卡名,逐批精準點名「這些卡名屬於庫 X」(見檔頭說明①)。
|
||||
source_prefix?: string; // metadata_json.$.source LIKE prefix%(過渡 fallback,見檔頭②)
|
||||
page_name_prefix?: string; // page_name LIKE prefix%(過渡 fallback,見檔頭②)
|
||||
since?: number; // created_at >= since(unix seconds)
|
||||
until?: number; // created_at < until(unix seconds)
|
||||
}
|
||||
|
||||
export interface LibraryBackfillResult {
|
||||
library: string;
|
||||
scanned: number; // 本批掃到的候選筆數(受 limit 限制,額度截斷前)。
|
||||
tagged: number; // 本次真的寫入 metadata_json.$.library 的筆數。
|
||||
remaining: number; // 本次之後仍待補標(符合條件、仍未標記)的筆數,不受額度影響。
|
||||
quota_limit: number; // 今日「背景維護 D1 寫入」額度上限(與 reconcile 共用)。
|
||||
quota_used_today: number; // 本次呼叫後,今日累積已消耗的背景維護寫入額度。
|
||||
quota_exceeded: boolean; // 本批是否因額度不足被截斷。
|
||||
}
|
||||
|
||||
// 單次呼叫候選上限(避開 subrequest/CPU/timeout;一批只有 1 次 SELECT + 1 次 UPDATE,
|
||||
// 比 reconcile 多一次 Vectorize 呼叫的成本低,故上限可以放寬一些)。
|
||||
const HARD_LIMIT_CAP = 500;
|
||||
|
||||
function criteriaPredicate(c: LibraryBackfillCriteria): { conds: string[]; params: unknown[] } {
|
||||
// 冪等的核心:只選「目前沒有 library 值」的候選,已標記過的(含標成 'general' 的)不會再入選。
|
||||
const conds: string[] = [
|
||||
"(json_extract(metadata_json, '$.library') IS NULL OR json_extract(metadata_json, '$.library') = '')",
|
||||
];
|
||||
const params: unknown[] = [];
|
||||
if (c.owner_id) { conds.push('owner_id = ?'); params.push(c.owner_id); }
|
||||
if (c.entry_type) { conds.push('entry_type = ?'); params.push(c.entry_type); }
|
||||
if (c.page_names && c.page_names.length > 0) {
|
||||
const names = c.page_names.slice(0, MAX_PAGE_NAMES);
|
||||
conds.push(`page_name IN (${names.map(() => '?').join(',')})`);
|
||||
params.push(...names);
|
||||
}
|
||||
if (c.source_prefix) { conds.push("json_extract(metadata_json, '$.source') LIKE ? || '%'"); params.push(c.source_prefix); }
|
||||
if (c.page_name_prefix) { conds.push("page_name LIKE ? || '%'"); params.push(c.page_name_prefix); }
|
||||
if (typeof c.since === 'number') { conds.push('created_at >= ?'); params.push(c.since); }
|
||||
if (typeof c.until === 'number') { conds.push('created_at < ?'); params.push(c.until); }
|
||||
return { conds, params };
|
||||
}
|
||||
|
||||
/**
|
||||
* 對「符合條件、目前未標記 library」的既有 entries 批次蓋上 target library 值。
|
||||
* 冪等 + 分批(單次 limit 上限)+ budget(與 reconcile 共用每日 D1 寫入額度,見檔頭)。
|
||||
* 呼叫端(ingest / Arcrun#87)決定「這批是誰、該貼哪個庫」,本函式只負責安全、節流地
|
||||
* 把值寫進去——base 不猜語意,也因此不假裝「這樣就把 #87 做完了」(mindset §7)。
|
||||
*
|
||||
* `owner_id` 刻意設成**必填**(不同於 LibraryBackfillCriteria 其餘欄位皆選填):
|
||||
* 2026-08-11 leo 在票上點出「補標補在錯的 owner 底下等於白做」(實查發現卡片實際掛在
|
||||
* `owner_id=bfezv28v`,換成 `owner_id='leo'` 查卻是空的——兩個候選 owner 已經在互相打架)。
|
||||
* 跟既有的 `deprecateEntriesByLibrary`(同樣是「依 library 批次改一大片既有資料」的操作)
|
||||
* 同一個防線:不給不知道自己在改誰的資料的呼叫端一個「忘記帶 owner_id 就變成跨租戶全庫掃」
|
||||
* 的後門,逼呼叫端明確想清楚「這批是哪個 owner」再動手。
|
||||
*/
|
||||
export async function backfillEntryLibraryTags(
|
||||
db: D1Database,
|
||||
env: Pick<Bindings, 'KBDB_MAINTENANCE_DAILY_WRITE_LIMIT'>,
|
||||
opts: { library: string; owner_id: string; limit?: number } & Omit<LibraryBackfillCriteria, 'owner_id'>,
|
||||
): Promise<LibraryBackfillResult> {
|
||||
const library = (opts.library ?? '').trim();
|
||||
if (!library) throw new Error('library required');
|
||||
const ownerId = (opts.owner_id ?? '').trim();
|
||||
if (!ownerId) throw new Error('owner_id required(標庫是跨大量既有資料的批次寫入,不准無租戶範圍地掃全庫——2026-08-11 leo 直令)');
|
||||
const limit = Math.min(Math.max(opts.limit ?? 100, 1), HARD_LIMIT_CAP);
|
||||
|
||||
const sel = criteriaPredicate({ ...opts, owner_id: ownerId });
|
||||
const where = sel.conds.join(' AND ');
|
||||
const params = sel.params;
|
||||
|
||||
const res = await db
|
||||
.prepare(`SELECT id FROM entries WHERE ${where} ORDER BY created_at ASC LIMIT ?`)
|
||||
.bind(...params, limit)
|
||||
.all<{ id: string }>();
|
||||
const scannedIds = (res.results ?? []).map((r) => r.id);
|
||||
const scanned = scannedIds.length;
|
||||
|
||||
// D69:額度截斷——每個候選最多 1 次 D1 write,與 reconcile 共用同一顆計數器。
|
||||
const budget = await maintenanceBudgetToday(env, db);
|
||||
const ids = scannedIds.slice(0, budget.remaining);
|
||||
const quotaExceeded = scanned > ids.length;
|
||||
|
||||
let tagged = 0;
|
||||
if (ids.length > 0) {
|
||||
const ph = ids.map(() => '?').join(',');
|
||||
await db
|
||||
.prepare(
|
||||
`UPDATE entries SET metadata_json = json_set(COALESCE(metadata_json, '{}'), '$.library', ?), updated_at = unixepoch() WHERE id IN (${ph})`,
|
||||
)
|
||||
.bind(library, ...ids)
|
||||
.run();
|
||||
tagged = ids.length;
|
||||
}
|
||||
|
||||
try {
|
||||
await addMaintenanceUsage(db, tagged);
|
||||
} catch {
|
||||
// fail-open:額度計數寫入失敗不影響已經完成的標庫寫入(精神同 embed.ts 的做法)。
|
||||
}
|
||||
|
||||
const remRow = await db
|
||||
.prepare(`SELECT COUNT(*) as c FROM entries WHERE ${where}`)
|
||||
.bind(...params)
|
||||
.first<{ c: number }>();
|
||||
|
||||
return {
|
||||
library,
|
||||
scanned,
|
||||
tagged,
|
||||
remaining: remRow?.c ?? 0,
|
||||
quota_limit: budget.limit,
|
||||
quota_used_today: budget.used + tagged,
|
||||
quota_exceeded: quotaExceeded,
|
||||
};
|
||||
}
|
||||
|
||||
/** 待補標統計(回報用):符合條件、目前未標記 library 的筆數。 */
|
||||
export async function libraryBackfillStatus(
|
||||
db: D1Database,
|
||||
opts: LibraryBackfillCriteria = {},
|
||||
): Promise<{ pending: number }> {
|
||||
const sel = criteriaPredicate(opts);
|
||||
const where = sel.conds.join(' AND ');
|
||||
const row = await db
|
||||
.prepare(`SELECT COUNT(*) as c FROM entries WHERE ${where}`)
|
||||
.bind(...sel.params)
|
||||
.first<{ c: number }>();
|
||||
return { pending: row?.c ?? 0 };
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
// 背景維護寫入的共用 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) };
|
||||
}
|
||||
Reference in New Issue
Block a user