diff --git a/kbdb/src/actions/library-map.ts b/kbdb/src/actions/library-map.ts index 8f817f1..2ec2b08 100644 --- a/kbdb/src/actions/library-map.ts +++ b/kbdb/src/actions/library-map.ts @@ -330,6 +330,15 @@ type LibraryNameSet = Set; // 這個 owner 底下、依 triplet 自身 'library' slot 分組的即時三元組數(缺 library slot 值的舊 // triplet 歸 'general')——與 GET /records/triplet-stats(t142)同一套分組語意,兩處數字對得上。 +// +// 2026-08-11 修根因(Arcrun#87,動工前量測 comment 第四節):這裡原本完全不過濾 status, +// 而 recomputeLibraryMap(上方 withLib)只算 COALESCE(status,'active')='active'。兩邊判準不 +// 一致,只要有一筆 superseded triplet,這裡的即時計數就會跟重算後的快取對不上, +// ensureFreshLibraryMaps 判定 stale,每次讀地圖都觸發重算,每次都新建一筆 library_map +// record(superseded 舊的),無止盡寫 D1,且加劇 recomputeLibraryMap 本身非原子 supersede +// 的競態(另一個已知病,wiki 08-10 條目)。實測:間隔數秒連讀兩次地圖、中間無任何寫入動作, +// updated_at 仍前進。修法:這裡的 status 判準改成與 recomputeLibraryMap 逐字一致,兩邊算出 +// 的計數才會在資料未變動時相等,stale 判定回歸「真的有資料變動才 stale」。 async function liveTripletCountsByLibrary( db: D1Database, tripletTemplateId: string, @@ -337,15 +346,18 @@ async function liveTripletCountsByLibrary( ): Promise { const params: unknown[] = owner_id ? [tripletTemplateId, owner_id] : [tripletTemplateId]; const res = await db - .prepare( + .prepare( // kbdb-sql-ok:牆內本體(kbdb/src/actions/),checkout 開在巢狀 worktree matrix/arcrun/.worktree-fix-87/(避免打斷另一 session 佔用中的 matrix/arcrun 主 checkout),hook 逐字比對 matrix/arcrun/kbdb/src/ 吃不到中間多出的 worktree 目錄層,非繞牆 `SELECT COALESCE(NULLIF(lib_e.content, ''), 'general') AS library, COUNT(*) AS n FROM ( - SELECT DISTINCT ev.record_id + SELECT ev.record_id AS rid, + MAX(CASE WHEN ev.slot_name = 'status' THEN e.content END) AS status FROM entry_values ev JOIN entries e ON ev.entry_id = e.id WHERE ev.template_id = ?${owner_id ? ' AND e.owner_id = ?' : ''} + GROUP BY ev.record_id ) AS tr - LEFT JOIN entry_values lev ON lev.record_id = tr.record_id AND lev.slot_name = 'library' + LEFT JOIN entry_values lev ON lev.record_id = tr.rid AND lev.slot_name = 'library' LEFT JOIN entries lib_e ON lib_e.id = lev.entry_id + WHERE COALESCE(tr.status, 'active') = 'active' GROUP BY COALESCE(NULLIF(lib_e.content, ''), 'general')`, ) .bind(...params) diff --git a/kbdb/tests/library-map.test.ts b/kbdb/tests/library-map.test.ts index 0d76e5c..149e2e2 100644 --- a/kbdb/tests/library-map.test.ts +++ b/kbdb/tests/library-map.test.ts @@ -16,7 +16,7 @@ import { ensureFreshLibraryMaps, LIBRARY_MAP_SLOTS, } from '../src/actions/library-map'; -import { createTemplate, createRecord, getRecord, getTemplate } from '../src/actions/record-crud'; +import { createTemplate, createRecord, getRecord, getTemplate, searchByTemplate } from '../src/actions/record-crud'; import { createEntry } from '../src/actions/entry-crud'; import type { Bindings } from '../src/types'; @@ -292,6 +292,39 @@ describe('M3 收尾 — 即時新鮮度(ensureFreshLibraryMaps,讀端自動 expect(secondBody.libraries.find((l) => l.library === 'kb')!.triplet_count).toBe(2); }); + it('Arcrun#87 迴歸:superseded triplet 存在時,連讀兩次地圖不會再次觸發重算(不再無止盡寫入)', async () => { + // 重現票上的根因:liveTripletCountsByLibrary 原本不濾 status,recomputeLibraryMap 只算 + // active——只要庫裡混了 superseded triplet,兩邊算出來的數字永遠對不上, + // ensureFreshLibraryMaps 就永遠判定 stale,每次讀地圖都重算、每次都新建一筆 record。 + const db = makeSqliteD1(); + await seedTripletTemplate(db); + await ensureTripletLibrarySlot(db, 'triplet'); + await seedTriplet(db, { s: 'A', p: '連結至', o: 'B', library: 'kb' }); // active + await seedTriplet(db, { s: 'A', p: '連結至', o: 'C', library: 'kb', status: 'superseded' }); // 已淘汰 + + const { app, env } = makeApp(db); + + // 第一次讀:資料是新的(從沒 recompute 過),觸發一次重算是正常的。 + const first = await app.request('/map', {}, env); + const firstBody = (await first.json()) as { libraries: { library: string; triplet_count: number }[] }; + expect(firstBody.libraries.find((l) => l.library === 'kb')!.triplet_count).toBe(1); // 只算 active 那筆 + + const countAfterFirst = (await searchByTemplate(db, 'library_map')).length; + + // 第二次讀:中間沒有任何寫入動作。修好之前,這裡會再次判定 stale 並多新建一筆 record。 + const second = await app.request('/map', {}, env); + const secondBody = (await second.json()) as { libraries: { library: string; triplet_count: number }[] }; + expect(secondBody.libraries.find((l) => l.library === 'kb')!.triplet_count).toBe(1); + + const countAfterSecond = (await searchByTemplate(db, 'library_map')).length; + expect(countAfterSecond).toBe(countAfterFirst); // 沒有新增任何 library_map record + + // 第三次也一樣,多讀幾次確認不是巧合。 + await app.request('/map', {}, env); + const countAfterThird = (await searchByTemplate(db, 'library_map')).length; + expect(countAfterThird).toBe(countAfterFirst); + }); + it('narrative 不會被自動重算靜默洗掉:先人工帶 narrative,之後的自動重算要保留它', async () => { const db = makeSqliteD1(); await seedTripletTemplate(db); diff --git a/system-dev/docs/3-specs/library-map/tasks.md b/system-dev/docs/3-specs/library-map/tasks.md index adbe99b..b0c5aea 100644 --- a/system-dev/docs/3-specs/library-map/tasks.md +++ b/system-dev/docs/3-specs/library-map/tasks.md @@ -44,3 +44,34 @@ leo 否決②——「**藏書地圖就是 arcrun 的最重要功能,讓 AI 不報錯。`mcp/tests/unit/tools/kbdb-map.test.ts` 新增 1 案釘住舊謊言不再出現(18/18 全綠)。 tsc 兩包乾淨。實測:`yuga3bse` 租戶(從未 backfill 過、真實 triplet 資料橫跨 5 個庫)改前 `kbdb_get_map` 回 `{libraries:[],count:0}`——改動待部署後需重新實測驗證非空。 + +### M3 止血(2026-08-11,Arcrun#87,總管交辦「動工前的量測」comment 第四節) + +**08-08 那次改法本身留了一個判準缺口,這次補上**:`ensureFreshLibraryMaps` 比對 +「即時三元組數」(`liveTripletCountsByLibrary`)與「快取的地圖數」(`recomputeLibraryMap` +算出來寫進去的),但兩邊的 status 過濾不一致——`recomputeLibraryMap` 只算 +`COALESCE(status,'active')='active'`,`liveTripletCountsByLibrary` 完全不濾 status。 +只要一個庫裡混了任何一筆 superseded/deprecated triplet,兩邊數字就永遠對不上, +`ensureFreshLibraryMaps` 就永遠判定 stale ⇒ **每次讀地圖都觸發重算,每次都新建一筆 +library_map record(superseded 舊的),無止盡寫 D1**——且加劇 `recomputeLibraryMap` +本身非原子 supersede 的既有競態(更高重算頻率 = 更高並發重算機率),是 `kb` 庫 +全部 44 筆被標 superseded、`notes` 庫兩筆同時 active(`arcrun-rag#50`)這兩個症狀的 +共同根因之一。 + +**修法**:`liveTripletCountsByLibrary`(`kbdb/src/actions/library-map.ts`)的 SQL 改成 +先 pivot 出每筆 triplet record 的 status,再套用與 `recomputeLibraryMap` 逐字一致的 +`COALESCE(status,'active')='active'` 過濾,兩邊判準對齊後,資料未變動時兩個計數必然相等, +stale 判定回歸「真的有資料變動才 stale」。 + +**驗證**:新增迴歸案「Arcrun#87 迴歸:superseded triplet 存在時,連讀兩次地圖不會再次 +觸發重算」(`kbdb/tests/library-map.test.ts`,19/19 全綠);反向驗證過——把同一顆測試跑在 +修前的舊 SQL 上會失敗(`library_map` record 數 2 vs 期望 1),證明測試真的釘住這個 bug、 +不是空氣測試。另外用 leo21c MCP 連線(`bfezv28v`)連讀兩次 `kbdb_get_map()`(無中間寫入) +獨立重現修前症狀:`general` 庫 `updated_at` 從 `1786457080` 前進到 `1786457114`。 + +**尚待**:改動只在分支 `fix/library-map-recompute-loop-87-v3`(未 push、未部署 leo21c); +既有 100 筆 library_map 殘骸(`kb` 44 筆 superseded/`general` 41/`notes` 2)未清—— +清除需要一個目前不存在的 DELETE 通道(cypher-executor 的 `/kbdb/records/:id` proxy 只有 +GET/POST/PATCH,無 DELETE;kbdb base 自己雖有 `DELETE /records/:recordId` 但走 leo21c +需要 `KBDB_INTERNAL_TOKEN`,非 CC 可持有的機密)——待總管部署本修法+視情況補一支 +DELETE proxy 後再清。