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
+99 -2
View File
@@ -83,7 +83,7 @@ async function countUsageRows(db: D1Database): Promise<{ id: string; entry_type:
return res.results;
}
function makeEnv(db: D1Database, opts: { withBindings?: boolean; dailyLimit?: string } = {}): Bindings {
function makeEnv(db: D1Database, opts: { withBindings?: boolean; dailyLimit?: string; maintenanceLimit?: string } = {}): Bindings {
const withBindings = opts.withBindings ?? true;
const upserts: { id: string }[] = [];
const aiCalls: string[][] = [];
@@ -93,6 +93,7 @@ function makeEnv(db: D1Database, opts: { withBindings?: boolean; dailyLimit?: st
DB: db,
ENVIRONMENT: 'test',
EMBED_BACKFILL_DAILY_LIMIT: opts.dailyLimit,
KBDB_MAINTENANCE_DAILY_WRITE_LIMIT: opts.maintenanceLimit,
...(withBindings
? {
AI: {
@@ -366,6 +367,102 @@ describe('D68③:leo21c 資料還原情境——is_embedded=1 但對應已退
insertEntry(db, { id: 'e1', content: 'x', created_at: 1, is_embedded: 1 });
const env = makeEnv(db, { withBindings: false });
const r = await reconcileEmbedGeneration(env);
expect(r).toEqual({ enabled: false, checked: 0, confirmed_current: 0, reset_to_pending: 0, remaining: 0 });
expect(r).toEqual({
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,
});
});
});
describe('Arcrun#85 D69reconcile 的 D1 寫入額度(與標庫 backfill 共用的計數器)', () => {
it('額度耗盡後 reconcile 停手:不再寫 D1quota_exceeded=true', async () => {
const db = makeSqliteD1();
insertEntry(db, { id: 'r1', content: 'c1', created_at: 1, is_embedded: 1, content_hash: null });
insertEntry(db, { id: 'r2', content: 'c2', created_at: 2, is_embedded: 1, content_hash: null });
insertEntry(db, { id: 'r3', content: 'c3', created_at: 3, is_embedded: 1, content_hash: null });
const env = makeEnv(db, { maintenanceLimit: '2' }); // 上限比候選數(3)小
seedVectorized(env, ['r1', 'r2', 'r3']); // 全在現行 indexconfirmed_current 路徑,仍是 D1 write
const r = await reconcileEmbedGeneration(env, { limit: 100 });
expect(r.scanned).toBe(3); // 掃到 3 筆候選
expect(r.checked).toBe(2); // 但只處理了額度允許的 2 筆
expect(r.confirmed_current).toBe(2);
expect(r.quota_limit).toBe(2);
expect(r.quota_used_today).toBe(2);
expect(r.quota_exceeded).toBe(true);
// 只有 2 筆真的被寫回 content_hash(最新的兩筆,ORDER BY created_at DESC
const rows = await listAllRows(db);
const byId = Object.fromEntries(rows.map((x) => [x.id, x]));
expect(byId.r3.content_hash).toBe(CURRENT_MODEL);
expect(byId.r2.content_hash).toBe(CURRENT_MODEL);
expect(byId.r1.content_hash).toBe(null); // 額度用完,沒輪到它
// 再跑一次(同一天):額度已用完,checked=0
const r2 = await reconcileEmbedGeneration(env, { limit: 100 });
expect(r2.checked).toBe(0);
expect(r2.quota_exceeded).toBe(true);
});
it('額度上限被拿掉時本測試會變紅(反向驗證,同 D68② 手法)', async () => {
const db = makeSqliteD1();
for (let i = 1; i <= 5; i++) insertEntry(db, { id: `r${i}`, content: `c${i}`, created_at: i, is_embedded: 1, content_hash: null });
const env = makeEnv(db); // 不設 maintenanceLimit → 用預設 20000,遠大於 5,全部應被處理
seedVectorized(env, ['r1', 'r2', 'r3', 'r4', 'r5']);
const r = await reconcileEmbedGeneration(env, { limit: 100 });
expect(r.checked).toBe(5);
expect(r.quota_exceeded).toBe(false);
const db2 = makeSqliteD1();
for (let i = 1; i <= 5; i++) insertEntry(db2, { id: `r${i}`, content: `c${i}`, created_at: i, is_embedded: 1, content_hash: null });
const env2 = makeEnv(db2, { maintenanceLimit: '2' });
seedVectorized(env2, ['r1', 'r2', 'r3', 'r4', 'r5']);
const r2 = await reconcileEmbedGeneration(env2, { limit: 100 });
expect(r2.checked).toBe(2);
expect(r2.checked).not.toBe(r.checked); // 有 cap vs 沒 cap 必須不同,否則 cap 沒在作用
});
});
describe('Arcrun#85:「挑哪一批」可以從外面指定(SelectionCriteriasince/until/library', () => {
it('backfillEmbeddings 帶 since/until 只補時間窗內的候選', async () => {
const db = makeSqliteD1();
insertEntry(db, { id: 'too-old', content: 'x', created_at: 100 });
insertEntry(db, { id: 'in-window-1', content: 'y', created_at: 500 });
insertEntry(db, { id: 'in-window-2', content: 'z', created_at: 800 });
insertEntry(db, { id: 'too-new', content: 'w', created_at: 1500 });
const env = makeEnv(db, { dailyLimit: '100' });
const r = await backfillEmbeddings(env, { limit: 100, since: 400, until: 1000 });
expect(r.processed).toBe(2);
expect((await listEmbeddedIds(db)).sort()).toEqual(['in-window-1', 'in-window-2']);
});
it('backfillEmbeddings 帶 library 只補該庫的候選(未標記歸 general)', async () => {
const db = makeSqliteD1();
insertEntry(db, { id: 'finance-1', content: 'x', created_at: 1, metadata_json: JSON.stringify({ embed: true, library: 'finance' }) });
insertEntry(db, { id: 'hr-1', content: 'y', created_at: 2, metadata_json: JSON.stringify({ embed: true, library: 'hr' }) });
insertEntry(db, { id: 'untagged', content: 'z', created_at: 3 }); // 無 library → general
const env = makeEnv(db, { dailyLimit: '100' });
const r = await backfillEmbeddings(env, { limit: 100, library: 'finance' });
expect(r.processed).toBe(1);
expect(await listEmbeddedIds(db)).toEqual(['finance-1']);
const r2 = await backfillEmbeddings(env, { limit: 100, library: 'general' });
expect(r2.processed).toBe(1);
expect((await listEmbeddedIds(db)).sort()).toEqual(['finance-1', 'untagged']);
});
it('reconcile 帶 since/until/library 同樣受篩選(同一套 SelectionCriteria,非獨立實作)', async () => {
const db = makeSqliteD1();
insertEntry(db, { id: 'old', content: 'x', created_at: 1, is_embedded: 1, content_hash: null, metadata_json: JSON.stringify({ embed: true, library: 'finance' }) });
insertEntry(db, { id: 'new', content: 'y', created_at: 100, is_embedded: 1, content_hash: null, metadata_json: JSON.stringify({ embed: true, library: 'finance' }) });
insertEntry(db, { id: 'other-lib', content: 'z', created_at: 100, is_embedded: 1, content_hash: null, metadata_json: JSON.stringify({ embed: true, library: 'hr' }) });
const env = makeEnv(db);
seedVectorized(env, ['old', 'new', 'other-lib']);
const r = await reconcileEmbedGeneration(env, { limit: 100, library: 'finance', since: 50 });
expect(r.checked).toBe(1);
const row = await getRow(db, 'new');
expect(row!.content_hash).toBe(CURRENT_MODEL);
const oldRow = await getRow(db, 'old');
expect(oldRow!.content_hash).toBe(null); // 在時間窗外,沒被動到
});
});
+230
View File
@@ -0,0 +1,230 @@
// 標庫 backfillArcrun#85 二次裁決,2026-08-11)測試。
//
// 測試策略比照 embed-backfill.test.ts:真 SQLitenode:sqlite)套 migrations/0001_base.sql
// 原檔,驗真實 SQL 語意(json_setWHERELIMIT),不是「以為 SQL 長這樣」。
//
// 覆蓋:
// 1. 只補「符合條件、目前未標記 library」的候選;已標記的不動(冪等)
// 2. owner_id 必填(缺了要拋錯,防「補錯 owner 等於白做」——2026-08-11 leo 直令)
// 3. source_prefixpage_name_prefixsinceuntil 篩選條件真的在篩
// 4. 與 reconcileEmbedGeneration 共用同一顆每日 D1 寫入額度(D69 的核心訴求:
// 補標不能把世代核對的閘繞過去,反之亦然)
//
// 本檔在 kbdb/tests/(牆外),依 D38 kbdb-api-wall-guard 規則,直接對 SQLite 治具下 SQL
// 的行集中在 helper(測試治具本身,非牆外業務邏輯繞過 API,每行標 kbdb-sql-ok 留痕)。
import { describe, it, expect } from 'vitest';
import { DatabaseSync } from 'node:sqlite';
import { readFileSync } from 'node:fs';
import { backfillEntryLibraryTags, libraryBackfillStatus } from '../src/actions/library-backfill';
import { reconcileEmbedGeneration } from '../src/embed';
import type { Bindings, Entry, EntryType } from '../src/types';
// ── node:sqlite → D1 介面最小 adapter(同 embed-backfill.test.ts 手法)──────────────
function makeSqliteD1(): D1Database {
const raw = new DatabaseSync(':memory:');
raw.exec(readFileSync(new URL('../migrations/0001_base.sql', import.meta.url), 'utf8')); // kbdb-sql-ok:測試治具(node:sqlite→D1 shim)套 migration 原檔
function stmt(sql: string, params: unknown[]) {
const s = {
bind(...args: unknown[]) { return stmt(sql, args); },
async all<T>() { return { results: raw.prepare(sql).all(...params) as T[] }; }, // kbdb-sql-ok:測試治具(node:sqlite→D1 shim
async first<T>() { return (raw.prepare(sql).get(...params) ?? null) as T | null; }, // kbdb-sql-ok:測試治具(node:sqlite→D1 shim
async run() {
const r = raw.prepare(sql).run(...params); // kbdb-sql-ok:測試治具(node:sqlite→D1 shim),非牆外業務邏輯繞過 API
return { success: true, meta: { changes: r.changes } };
},
};
return s;
}
return { prepare: (sql: string) => stmt(sql, []) } as unknown as D1Database;
}
function insertEntry(db: D1Database, e: Partial<Entry> & { id: string; created_at: number }): void {
const sql = `INSERT INTO entries (id, content, entry_type, owner_id, content_hash, is_embedded, metadata_json, page_name, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`;
db.prepare(sql).bind( // kbdb-sql-ok:測試治具(node:sqlite→D1 shim)灌測試資料
e.id,
e.content === undefined ? 'x' : e.content,
(e.entry_type ?? 'block') as EntryType,
e.owner_id ?? 'bfezv28v',
e.content_hash ?? null,
e.is_embedded ?? 0,
e.metadata_json === undefined ? null : e.metadata_json,
e.page_name ?? null,
e.created_at,
e.created_at,
).run();
}
async function getLibrary(db: D1Database, id: string): Promise<string | null> {
const row = await db.prepare("SELECT json_extract(metadata_json, '$.library') AS library FROM entries WHERE id = ?").bind(id).first<{ library: string | null }>(); // kbdb-sql-ok:測試治具讀回斷言用
return row?.library ?? null;
}
function makeEnv(db: D1Database, opts: { maintenanceLimit?: string } = {}): Bindings {
return {
DB: db,
ENVIRONMENT: 'test',
KBDB_MAINTENANCE_DAILY_WRITE_LIMIT: opts.maintenanceLimit,
} as unknown as Bindings;
}
describe('backfillEntryLibraryTags — 基本行為', () => {
it('只標記符合條件、目前未標記 library 的候選;已標記的不動', async () => {
const db = makeSqliteD1();
insertEntry(db, { id: 'a', created_at: 1 }); // 無 metadata_json → 未標記
insertEntry(db, { id: 'b', created_at: 2, metadata_json: JSON.stringify({}) }); // 有 metadata_json 但無 library
insertEntry(db, { id: 'c', created_at: 3, metadata_json: JSON.stringify({ library: 'hr' }) }); // 已標記,不該被動
const env = makeEnv(db);
const r = await backfillEntryLibraryTags(db, env, { library: 'finance', owner_id: 'bfezv28v' });
expect(r.tagged).toBe(2);
expect(r.remaining).toBe(0);
expect(await getLibrary(db, 'a')).toBe('finance');
expect(await getLibrary(db, 'b')).toBe('finance');
expect(await getLibrary(db, 'c')).toBe('hr'); // 未被覆寫
});
it('冪等:全部標記完後重跑不再處理', async () => {
const db = makeSqliteD1();
insertEntry(db, { id: 'a', created_at: 1 });
const env = makeEnv(db);
await backfillEntryLibraryTags(db, env, { library: 'finance', owner_id: 'bfezv28v' });
const r2 = await backfillEntryLibraryTags(db, env, { library: 'finance', owner_id: 'bfezv28v' });
expect(r2.tagged).toBe(0);
expect(r2.remaining).toBe(0);
});
it('owner_id 缺了要拋錯(防補錯 owner 等於白做,2026-08-11 leo 直令)', async () => {
const db = makeSqliteD1();
insertEntry(db, { id: 'a', created_at: 1 });
const env = makeEnv(db);
await expect(
backfillEntryLibraryTags(db, env, { library: 'finance', owner_id: '' }),
).rejects.toThrow(/owner_id/);
});
it('library 缺了要拋錯', async () => {
const db = makeSqliteD1();
const env = makeEnv(db);
await expect(
backfillEntryLibraryTags(db, env, { library: '', owner_id: 'bfezv28v' }),
).rejects.toThrow(/library/);
});
it('owner_id 篩選:只動指定租戶的資料,其他租戶不受影響(跨租戶隔離)', async () => {
const db = makeSqliteD1();
insertEntry(db, { id: 'mine', created_at: 1, owner_id: 'bfezv28v' });
insertEntry(db, { id: 'theirs', created_at: 2, owner_id: 'someone-else' });
const env = makeEnv(db);
const r = await backfillEntryLibraryTags(db, env, { library: 'finance', owner_id: 'bfezv28v' });
expect(r.tagged).toBe(1);
expect(await getLibrary(db, 'mine')).toBe('finance');
expect(await getLibrary(db, 'theirs')).toBe(null); // 別的租戶完全沒被動到
});
it('source_prefixpage_name_prefixsinceuntil 篩選真的在篩', async () => {
const db = makeSqliteD1();
insertEntry(db, { id: 'match-source', created_at: 500, metadata_json: JSON.stringify({ source: 'gitea://Leo/kb/foo.md' }) });
insertEntry(db, { id: 'other-source', created_at: 500, metadata_json: JSON.stringify({ source: 'gitea://Leo/other/bar.md' }) });
const r1 = await backfillEntryLibraryTags(db, makeEnv(db), {
library: 'kb', owner_id: 'bfezv28v', source_prefix: 'gitea://Leo/kb/',
});
expect(r1.tagged).toBe(1);
expect(await getLibrary(db, 'match-source')).toBe('kb');
expect(await getLibrary(db, 'other-source')).toBe(null);
const db2 = makeSqliteD1();
insertEntry(db2, { id: 'in-window', created_at: 500, page_name: 'wiki/foo' });
insertEntry(db2, { id: 'out-window', created_at: 5000, page_name: 'wiki/bar' });
const r2 = await backfillEntryLibraryTags(db2, makeEnv(db2), {
library: 'wiki', owner_id: 'bfezv28v', page_name_prefix: 'wiki/', since: 0, until: 1000,
});
expect(r2.tagged).toBe(1);
expect(await getLibrary(db2, 'in-window')).toBe('wiki');
expect(await getLibrary(db2, 'out-window')).toBe(null);
});
it('page_names 精準比對(leo 定案的正解:拿 Gitea 原稿卡名逐批遍歷點名)', async () => {
const db = makeSqliteD1();
insertEntry(db, { id: 'a', created_at: 1, page_name: 'card-alpha' });
insertEntry(db, { id: 'b', created_at: 2, page_name: 'card-beta' });
insertEntry(db, { id: 'c', created_at: 3, page_name: 'card-gamma' }); // 不在點名清單內
const r = await backfillEntryLibraryTags(db, makeEnv(db), {
library: 'kb', owner_id: 'bfezv28v', page_names: ['card-alpha', 'card-beta'],
});
expect(r.tagged).toBe(2);
expect(await getLibrary(db, 'a')).toBe('kb');
expect(await getLibrary(db, 'b')).toBe('kb');
expect(await getLibrary(db, 'c')).toBe(null); // 沒被點名,不動
});
it('libraryBackfillStatus 回報待補標筆數', async () => {
const db = makeSqliteD1();
insertEntry(db, { id: 'a', created_at: 1 });
insertEntry(db, { id: 'b', created_at: 2, metadata_json: JSON.stringify({ library: 'hr' }) });
const s = await libraryBackfillStatus(db, { owner_id: 'bfezv28v' });
expect(s.pending).toBe(1); // 只有 'a' 未標記
});
});
describe('Arcrun#85 D69:標庫 backfill 與 reconcile 共用同一顆 D1 每日寫入額度', () => {
it('reconcile 先消耗額度 → 標庫 backfill 看到的剩餘額度真的變少', async () => {
const db = makeSqliteD1();
// reconcile 的候選:is_embedded=1 且 content_hash 非現行世代
// library 已標記('hr')→ 不會被下面的標庫 backfill 選中,讓兩種候選池互不重疊,
// 才能單純驗證「額度共用」本身,不被「標庫候選也吃到 reconcile 資料」干擾。
insertEntry(db, { id: 'reconcile-1', created_at: 1, is_embedded: 1, content_hash: null, metadata_json: JSON.stringify({ embed: true, library: 'hr' }) });
insertEntry(db, { id: 'reconcile-2', created_at: 2, is_embedded: 1, content_hash: null, metadata_json: JSON.stringify({ embed: true, library: 'hr' }) });
// 標庫的候選:未標記 library
insertEntry(db, { id: 'tag-1', created_at: 3 });
insertEntry(db, { id: 'tag-2', created_at: 4 });
insertEntry(db, { id: 'tag-3', created_at: 5 });
const maintenanceLimit = '3'; // 5 個候選(2 reconcile + 3 tag),額度只夠 3 個
const reconcileEnv = {
DB: db, ENVIRONMENT: 'test', KBDB_MAINTENANCE_DAILY_WRITE_LIMIT: maintenanceLimit,
AI: { async run() { return { data: [] }; } },
VECTORIZE: {
async getByIds(ids: string[]) { return ids.map((id) => ({ id, values: [0.1] })); }, // 全部視為現行 index 已有
async upsert() { return { count: 0 }; },
},
} as unknown as Bindings;
// 先跑 reconcile:吃掉 2 筆額度(3 - 2 = 1 剩)
const rc = await reconcileEmbedGeneration(reconcileEnv, { limit: 100 });
expect(rc.checked).toBe(2);
expect(rc.quota_used_today).toBe(2);
// 標庫 backfill 用同一顆 DB/同一個每日上限:只剩 1 筆額度可用,即使候選有 3 筆
const tagEnv = makeEnv(db, { maintenanceLimit });
const tagResult = await backfillEntryLibraryTags(db, tagEnv, { library: 'general', owner_id: 'bfezv28v' });
expect(tagResult.scanned).toBe(3); // 3 筆候選都掃到了
expect(tagResult.tagged).toBe(1); // 但只剩 1 筆額度,只標了 1 筆
expect(tagResult.quota_exceeded).toBe(true);
expect(tagResult.quota_used_today).toBe(3); // 2reconcile+ 1(本次)= 3,額度用滿
});
it('反過來也一樣:標庫 backfill 先消耗額度 → reconcile 看到的剩餘額度真的變少', async () => {
const db = makeSqliteD1();
insertEntry(db, { id: 'tag-1', created_at: 1 });
insertEntry(db, { id: 'tag-2', created_at: 2 });
insertEntry(db, { id: 'reconcile-1', created_at: 3, is_embedded: 1, content_hash: null, metadata_json: JSON.stringify({ embed: true }) });
const maintenanceLimit = '2';
const tagEnv = makeEnv(db, { maintenanceLimit });
const tagResult = await backfillEntryLibraryTags(db, tagEnv, { library: 'general', owner_id: 'bfezv28v' });
expect(tagResult.tagged).toBe(2); // 額度剛好夠標完兩筆
const reconcileEnv = {
DB: db, ENVIRONMENT: 'test', KBDB_MAINTENANCE_DAILY_WRITE_LIMIT: maintenanceLimit,
AI: { async run() { return { data: [] }; } },
VECTORIZE: {
async getByIds(ids: string[]) { return ids.map((id) => ({ id, values: [0.1] })); },
async upsert() { return { count: 0 }; },
},
} as unknown as Bindings;
const rc = await reconcileEmbedGeneration(reconcileEnv, { limit: 100 });
expect(rc.scanned).toBe(1); // 有 1 筆候選
expect(rc.checked).toBe(0); // 但額度已被標庫 backfill 用光,reconcile 一筆都動不了
expect(rc.quota_exceeded).toBe(true);
});
});