Commit d279c17ff099187639170c797b1cd63df2c4a48d
1 parent
0ad9af59
fix(files-index): 删掉 graph_files 的旧无条件唯一索引 —— 存量库上产物索引一直在静默丢
## 病症(真跑实测)
2026-08-20 那条 run 的 timeline 里,**每个节点**的产物索引写入都失败:
node.output | 产物索引写入失败 contract.json: UNIQUE constraint failed: index 'idx_graph_files_path'
node.output | 产物索引写入失败 sdd.md: UNIQUE constraint failed: index 'idx_graph_files_path'
19 次 agent 调用、每次两三个文件,无一成功。
## 根因:加了新索引、没删旧索引
产物索引加「版本」之前,唯一索引是无条件的
`UNIQUE(company_id, COALESCE(task_id,''), rel_path)`。加版本之后建表块换成了
带谓词的 idx_graph_files_current(WHERE superseded_by IS NULL),**但没人删旧的那条** ——
`CREATE ... IF NOT EXISTS` 只管不重复建,不会删已有的。
于是 recordFile 那套「先把旧行 superseded_by 指向新行、再插新行」的版本化写法
在存量库上整个失效:旧索引不认 superseded_by,同路径第二行直接撞。
而重试恰恰会让同一条 rel_path 被产出第二次。
## 失效方向:静默
writeFileIndex 把 recordFile 的抛降级成一条 timeline,**节点照样 success**。
后果是产物索引在任何有重试的 run 上都不全 —— 而下游节点正是靠这份索引
按 kind 找上游产物。不报错、不影响测试、git status 干净。
## 为什么没有任何测试照到
新建库压根没有那条索引(建表块里早就只剩 current 那条),**只有存量库会踩**。
这是同一类问题的**第三次**(tasks 加列的索引顺序、墙钟锚点迁移、这次)。
判据固定成一句话:**凡是碰 schema,「存量库重启」那条路必须单独走一遍。**
## 修 + 验
迁移里一句 DROP INDEX IF EXISTS idx_graph_files_path。
守卫 graph-files-stale-index.test.js 走存量库重启那条路,并验版本化语义没被削弱
(同路径两行、当前版本恰好一条、旧行指向新行)。反向验过:不删就红。
生产库已实跑验证:删索引后同路径连产两次不再抛,graph_files 24 行未受影响
(备份 /tmp/graph.sqlite.pre-idxdrop)。
全量:141 文件 / 1300 测试全绿。
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Showing
3 changed files
with
121 additions
and
3 deletions
docs/todo/system-design/27-p3b-real-run-runway.md
| ... | ... | @@ -995,6 +995,21 @@ node.output | 产物索引写入失败 contract.json: UNIQUE constraint failed: |
| 995 | 995 | node.output | 产物索引写入失败 sdd.md: UNIQUE constraint failed: index 'idx_graph_files_path' |
| 996 | 996 | ``` |
| 997 | 997 | |
| 998 | -形态是重试时同一条产物路径被再写一次撞唯一索引。后果不是崩溃 —— | |
| 999 | -**是 `files.json` 产物索引在有重试的 run 上静默不全**,而那正是 | |
| 1000 | -[27-P4](./27-p4-parallel-and-artifacts.md) 要建的东西。归到 P4 一起处理。 | |
| 998 | +**根因已查明并修复(2026-08-20)**,而且它是个**存量库专属**缺陷: | |
| 999 | + | |
| 1000 | +生产库里还留着产物索引加「版本」之前的那条**无条件**唯一索引 | |
| 1001 | +`idx_graph_files_path`(`UNIQUE(company_id, COALESCE(task_id,''), rel_path)`)。 | |
| 1002 | +加版本之后建表块换成了带谓词的 `idx_graph_files_current`(`WHERE superseded_by IS NULL`), | |
| 1003 | +**但没人删旧的那条** —— `CREATE ... IF NOT EXISTS` 只管不重复建,不会删已有的。 | |
| 1004 | + | |
| 1005 | +于是 `recordFile` 那套「先把旧行 `superseded_by` 指向新行、再插新行」的版本化写法 | |
| 1006 | +在存量库上整个失效:旧索引不认 `superseded_by`,同路径第二行直接撞。 | |
| 1007 | +新建库压根没有那条索引,所以**只有存量库会踩**,任何单测都照不到。 | |
| 1008 | + | |
| 1009 | +修法是迁移里一句 `DROP INDEX IF EXISTS idx_graph_files_path`, | |
| 1010 | +守卫 `graph-files-stale-index.test.js` 走存量库重启那条路(反向验过:不删就红)。 | |
| 1011 | +生产库已实跑验证:删索引后同路径连产两次不再抛,当前版本仍只有一条。 | |
| 1012 | + | |
| 1013 | +> 这是**同一类问题的第三次**(`tasks` 加列的索引顺序、墙钟锚点迁移、这次)。 | |
| 1014 | +> 判据可以固定成一句话:**凡是碰 schema,「存量库重启」那条路必须单独走一遍** | |
| 1015 | +> —— 新建库的路径由单测天然覆盖,存量库的不会。 | ... | ... |
server/services/work-graph-db.js
| ... | ... | @@ -549,6 +549,25 @@ function getDb() { |
| 549 | 549 | // 吃一句 SQLite 原文的 500(isSlotRaceError 已不再吞它,就是为了让这一幕吵起来)。 |
| 550 | 550 | db.exec(`DROP INDEX IF EXISTS idx_graph_runs_one_active_root`); |
| 551 | 551 | |
| 552 | + // ── DROP 掉 graph_files 的**旧无条件**唯一索引 idx_graph_files_path ────────── | |
| 553 | + // | |
| 554 | + // 它是产物索引加「版本」之前的形态:`UNIQUE(company_id, COALESCE(task_id,''), rel_path)`, | |
| 555 | + // **没有** `WHERE superseded_by IS NULL`。后来加了部分索引 idx_graph_files_current, | |
| 556 | + // 却没删掉旧的 —— 于是 recordFile 的「先把旧行 superseded_by 指向新行、再插新行」 | |
| 557 | + // 这套版本化写法在**存量库上整个失效**:旧索引不认 superseded_by,同路径第二行直接撞。 | |
| 558 | + // | |
| 559 | + // 症状是静默的:recordFile 抛,被 writeFileIndex 降级成一条 timeline | |
| 560 | + // (`产物索引写入失败 xxx: UNIQUE constraint failed: index 'idx_graph_files_path'`), | |
| 561 | + // 节点照样 success。后果是**产物索引在任何有重试的 run 上都不全** —— 而下游节点 | |
| 562 | + // 正是靠这份索引按 kind 找上游产物的。 | |
| 563 | + // | |
| 564 | + // 2026-08-20 真跑实测:一条 run 里每个节点的产物索引写入**全部**失败(19 次调用、 | |
| 565 | + // 每次两三个文件),因为重试让同一条 rel_path 被产出了第二次。 | |
| 566 | + // | |
| 567 | + // 新建库没有这条索引(建表块里早就只剩 current 那条),所以**只有存量库会踩** —— | |
| 568 | + // 又一次「新建库的路径由单测天然覆盖、存量库那条得另外安排人走」。 | |
| 569 | + db.exec(`DROP INDEX IF EXISTS idx_graph_files_path`); | |
| 570 | + | |
| 552 | 571 | // ── 批 5:tasks 追加三列(§十二.3)────────────────────────────── |
| 553 | 572 | // 旧行的落点都对:executor 缺省 'graph'(批 1-4 建的全是图任务), |
| 554 | 573 | // assignee_agent_id / parent_task_id 为 NULL(图任务没有派单对象、也不在派单链上)。 | ... | ... |
server/tests/unit/graph-files-stale-index.test.js
0 → 100644
| 1 | +/** | |
| 2 | + * `graph_files` 的**旧无条件唯一索引** `idx_graph_files_path` 必须在存量库上被删掉。 | |
| 3 | + * | |
| 4 | + * ── 病症 ────────────────────────────────────────────────────────────── | |
| 5 | + * 产物索引加「版本」之前,唯一索引是无条件的: | |
| 6 | + * UNIQUE(company_id, COALESCE(task_id,''), rel_path) | |
| 7 | + * 加版本之后建表块换成了带谓词的 `idx_graph_files_current` | |
| 8 | + * ... WHERE superseded_by IS NULL | |
| 9 | + * **但没人删旧的那条**。`CREATE ... IF NOT EXISTS` 只管不重复建,不会删已有的。 | |
| 10 | + * | |
| 11 | + * 于是 `recordFile` 那套「先把旧行 superseded_by 指向新行、再插新行」的版本化写法 | |
| 12 | + * 在存量库上**整个失效** —— 旧索引不认 superseded_by,同路径的第二行直接撞。 | |
| 13 | + * | |
| 14 | + * ── 为什么没被任何测试照到 ──────────────────────────────────────────── | |
| 15 | + * 新建库压根没有那条索引(建表块里早就只剩 current),所以**只有存量库会踩**。 | |
| 16 | + * 这是同一类问题的第三次(tasks 加列的索引顺序、墙钟锚点迁移、这次)—— | |
| 17 | + * 判据固定下来就是一句话:**凡是碰 schema,「存量库重启」那条路必须单独走一遍。** | |
| 18 | + * | |
| 19 | + * ── 失效方向 ────────────────────────────────────────────────────────── | |
| 20 | + * 不是崩溃:`writeFileIndex` 把 `recordFile` 的抛降级成一条 timeline,节点照样 success。 | |
| 21 | + * 后果是**产物索引在任何有重试的 run 上都不全**,而下游节点正是靠它按 kind 找上游产物。 | |
| 22 | + * 2026-08-20 那次真跑里,一条 run 的**每个**节点产物索引写入全部失败。 | |
| 23 | + */ | |
| 24 | +import { describe, it, expect } from 'vitest'; | |
| 25 | +import { createRequire } from 'module'; | |
| 26 | +import fs from 'fs'; | |
| 27 | +import os from 'os'; | |
| 28 | +import path from 'path'; | |
| 29 | +const require_ = createRequire(import.meta.url); | |
| 30 | + | |
| 31 | +function freshDb(dbPath) { | |
| 32 | + process.env.GRAPH_DB_PATH = dbPath; | |
| 33 | + for (const m of ['../../services/work-graph-db', '../../services/graph-db']) { | |
| 34 | + delete require_.cache[require_.resolve(m)]; | |
| 35 | + } | |
| 36 | + const wdb = require_('../../services/work-graph-db'); | |
| 37 | + wdb.getDb(); | |
| 38 | + return wdb; | |
| 39 | +} | |
| 40 | + | |
| 41 | +const indexNames = (db) => db | |
| 42 | + .prepare(`SELECT name FROM sqlite_master WHERE type='index' AND tbl_name='graph_files'`) | |
| 43 | + .all().map((r) => r.name); | |
| 44 | + | |
| 45 | +describe('graph_files 旧唯一索引的清理', () => { | |
| 46 | + it('存量库重启:旧的 idx_graph_files_path 被删掉,同路径可以再产出一次', () => { | |
| 47 | + const dbPath = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'gf-idx-')), 'graph.sqlite'); | |
| 48 | + const wdb = freshDb(dbPath); | |
| 49 | + expect(indexNames(wdb.getDb())).not.toContain('idx_graph_files_path'); // 新建库本来就没有 | |
| 50 | + wdb.getDb().close(); | |
| 51 | + | |
| 52 | + // 退回加版本之前的形态:把旧的无条件唯一索引建回来 | |
| 53 | + const Database = require_('better-sqlite3'); | |
| 54 | + const raw = new Database(dbPath); | |
| 55 | + raw.exec(`CREATE UNIQUE INDEX idx_graph_files_path | |
| 56 | + ON graph_files(company_id, COALESCE(task_id, ''), rel_path)`); | |
| 57 | + expect(raw.prepare(`SELECT name FROM sqlite_master WHERE type='index' AND tbl_name='graph_files'`) | |
| 58 | + .all().map((r) => r.name)).toContain('idx_graph_files_path'); | |
| 59 | + raw.close(); | |
| 60 | + | |
| 61 | + // 重开进程 = 服务重启 | |
| 62 | + const wdb2 = freshDb(dbPath); | |
| 63 | + expect(indexNames(wdb2.getDb())).not.toContain('idx_graph_files_path'); | |
| 64 | + // 部分索引必须还在 —— 删错了那条会让「同一路径同时有两个当前版本」变成可能 | |
| 65 | + expect(indexNames(wdb2.getDb())).toContain('idx_graph_files_current'); | |
| 66 | + | |
| 67 | + // 真正要的行为:同一条 rel_path 产出两次(= 节点重试),第二次不该抛 | |
| 68 | + const args = { companyId: 'co-x', taskId: 'T-x', relPath: 'nodes/rn_1/sdd.md', kind: 'sdd' }; | |
| 69 | + const first = wdb2.recordFile(args); | |
| 70 | + const second = wdb2.recordFile(args); // 修之前这一句抛 UNIQUE constraint failed | |
| 71 | + expect(second).not.toBe(first); | |
| 72 | + | |
| 73 | + // 旧行被指向新行,当前版本只有一条 —— 版本化语义没被削弱 | |
| 74 | + const rows = wdb2.getDb() | |
| 75 | + .prepare(`SELECT id, superseded_by FROM graph_files WHERE rel_path = ?`).all(args.relPath); | |
| 76 | + expect(rows).toHaveLength(2); | |
| 77 | + expect(rows.filter((r) => r.superseded_by === null)).toHaveLength(1); | |
| 78 | + expect(rows.find((r) => r.id === first).superseded_by).toBe(second); | |
| 79 | + | |
| 80 | + wdb2.getDb().close(); | |
| 81 | + fs.rmSync(path.dirname(dbPath), { recursive: true, force: true }); | |
| 82 | + delete process.env.GRAPH_DB_PATH; | |
| 83 | + }); | |
| 84 | +}); | ... | ... |