graph-files-stale-index.test.js
4.62 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
/**
* `graph_files` 的**旧无条件唯一索引** `idx_graph_files_path` 必须在存量库上被删掉。
*
* ── 病症 ──────────────────────────────────────────────────────────────
* 产物索引加「版本」之前,唯一索引是无条件的:
* 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,同路径的第二行直接撞。
*
* ── 为什么没被任何测试照到 ────────────────────────────────────────────
* 新建库压根没有那条索引(建表块里早就只剩 current),所以**只有存量库会踩**。
* 这是同一类问题的第三次(tasks 加列的索引顺序、墙钟锚点迁移、这次)——
* 判据固定下来就是一句话:**凡是碰 schema,「存量库重启」那条路必须单独走一遍。**
*
* ── 失效方向 ──────────────────────────────────────────────────────────
* 不是崩溃:`writeFileIndex` 把 `recordFile` 的抛降级成一条 timeline,节点照样 success。
* 后果是**产物索引在任何有重试的 run 上都不全**,而下游节点正是靠它按 kind 找上游产物。
* 2026-08-20 那次真跑里,一条 run 的**每个**节点产物索引写入全部失败。
*/
import { describe, it, expect } from 'vitest';
import { createRequire } from 'module';
import fs from 'fs';
import os from 'os';
import path from 'path';
const require_ = createRequire(import.meta.url);
function freshDb(dbPath) {
process.env.GRAPH_DB_PATH = dbPath;
for (const m of ['../../services/work-graph-db', '../../services/graph-db']) {
delete require_.cache[require_.resolve(m)];
}
const wdb = require_('../../services/work-graph-db');
wdb.getDb();
return wdb;
}
const indexNames = (db) => db
.prepare(`SELECT name FROM sqlite_master WHERE type='index' AND tbl_name='graph_files'`)
.all().map((r) => r.name);
describe('graph_files 旧唯一索引的清理', () => {
it('存量库重启:旧的 idx_graph_files_path 被删掉,同路径可以再产出一次', () => {
const dbPath = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'gf-idx-')), 'graph.sqlite');
const wdb = freshDb(dbPath);
expect(indexNames(wdb.getDb())).not.toContain('idx_graph_files_path'); // 新建库本来就没有
wdb.getDb().close();
// 退回加版本之前的形态:把旧的无条件唯一索引建回来
const Database = require_('better-sqlite3');
const raw = new Database(dbPath);
raw.exec(`CREATE UNIQUE INDEX idx_graph_files_path
ON graph_files(company_id, COALESCE(task_id, ''), rel_path)`);
expect(raw.prepare(`SELECT name FROM sqlite_master WHERE type='index' AND tbl_name='graph_files'`)
.all().map((r) => r.name)).toContain('idx_graph_files_path');
raw.close();
// 重开进程 = 服务重启
const wdb2 = freshDb(dbPath);
expect(indexNames(wdb2.getDb())).not.toContain('idx_graph_files_path');
// 部分索引必须还在 —— 删错了那条会让「同一路径同时有两个当前版本」变成可能
expect(indexNames(wdb2.getDb())).toContain('idx_graph_files_current');
// 真正要的行为:同一条 rel_path 产出两次(= 节点重试),第二次不该抛
const args = { companyId: 'co-x', taskId: 'T-x', relPath: 'nodes/rn_1/sdd.md', kind: 'sdd' };
const first = wdb2.recordFile(args);
const second = wdb2.recordFile(args); // 修之前这一句抛 UNIQUE constraint failed
expect(second).not.toBe(first);
// 旧行被指向新行,当前版本只有一条 —— 版本化语义没被削弱
const rows = wdb2.getDb()
.prepare(`SELECT id, superseded_by FROM graph_files WHERE rel_path = ?`).all(args.relPath);
expect(rows).toHaveLength(2);
expect(rows.filter((r) => r.superseded_by === null)).toHaveLength(1);
expect(rows.find((r) => r.id === first).superseded_by).toBe(second);
wdb2.getDb().close();
fs.rmSync(path.dirname(dbPath), { recursive: true, force: true });
delete process.env.GRAPH_DB_PATH;
});
});