fix(kbdb): 藏書地圖讀端每次都觸發重算寫 D1(Arcrun#87 止血)
liveTripletCountsByLibrary 沒有過濾 status,recomputeLibraryMap 只算 COALESCE(status,'active')='active'——兩邊判準不一致,只要庫裡有一筆 superseded triplet 就永遠判定 stale,導致每次讀地圖(GET /map、GET /map/:library、 kbdb_get_map MCP 工具)都觸發重算並新建一筆 library_map record,無止盡寫 D1, 且加劇既有的非原子 supersede 競態(kb 44 筆全 superseded、notes 兩筆同時 active 即 arcrun-rag#50 的共同根因之一)。 修法:liveTripletCountsByLibrary 的 SQL 改成 pivot 出 status 再套用與 recomputeLibraryMap 逐字一致的過濾,兩邊判準對齊。 新增迴歸測試釘住此 bug(反向驗證:跑在修前的 SQL 上會失敗,非空氣測試); 獨立用 leo21c MCP 連線連讀兩次 kbdb_get_map() 重現修前症狀 (general 庫 updated_at 從 1786457080 前進到 1786457114,中間無寫入)。 kbdb/tests/library-map.test.ts 19/19 全綠。既有殘骸(100 筆 library_map record) 未清——目前沒有可用的 DELETE 通道,待部署後另行處理。 kbdb-sql-ok:liveTripletCountsByLibrary 的 .prepare 呼叫是牆內本體 (kbdb/src/actions/),本次 checkout 開在巢狀 worktree matrix/arcrun/.worktree-fix-87/(避免打斷另一 session 佔用中的 matrix/arcrun 主 checkout),kbdb-api-wall-guard.sh 的字面路徑比對 *matrix/arcrun/kbdb/src/* 吃不到中間多出的 worktree 目錄層,非真的繞牆。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -330,6 +330,15 @@ type LibraryNameSet = Set<string>;
|
||||
|
||||
// 這個 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<LibraryCountMap> {
|
||||
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)
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 後再清。
|
||||
|
||||
Reference in New Issue
Block a user