fix(db): make edge rebinding atomic (#1833) (#1849)

Co-authored-by: Colby Mchenry <me@colbymchenry.com>
This commit is contained in:
Christopher Beaulieu
2026-09-15 01:47:26 -04:00
committed by GitHub
parent 4297b8e2ed
commit 58c07e8745
4 changed files with 98 additions and 2 deletions
+2
View File
@@ -157,6 +157,8 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
#### MCP / indexing
- Incremental sync now keeps edge rebinding crash-safe: replacing a resolved edge with its recovery reference commits atomically, so an interruption cannot permanently remove the relationship.
- The prompt hook no longer injects unrelated projects when run from your home directory or a broader directory containing a stray workspace manifest. (#1454)
- Indexing now succeeds when Node.js's SQLite lacks FTS5, with search falling back to name and fuzzy matching; thanks @aniruddhaadak80. (#1532)
@@ -146,6 +146,86 @@ describe('Incremental sync converges to a full rebuild (CG-33)', () => {
expect(describeDiff(synced, rebuilt)).toBe('missing from synced: 0, stale in synced: 0');
});
it('keeps one edge when re-resolution selects the same target', async () => {
write('src/caller.ts', `export function run(): number {\n return pct(1);\n}\n`);
write('src/alpha.ts', `export function pct(n: number): number {\n return n;\n}\n`);
cg = CodeGraph.initSync(testDir, { config: { include: ['**/*.ts'], exclude: [] } });
await cg.indexAll();
// zeta.ts introduces a competing definition, so the existing edge is
// reopened, but alpha.ts remains the deterministic first candidate.
write('src/zeta.ts', `export function pct(n: number): number {\n return n * 2;\n}\n`);
const result = await cg.sync();
expect(result.definitionDelta).toContain('pct');
const targets = withDb((db) =>
(
db
.prepare(
`SELECT target.file_path AS file
FROM edges edge
JOIN nodes source ON source.id = edge.source
JOIN nodes target ON target.id = edge.target
WHERE source.name = 'run'
AND target.name = 'pct'
AND edge.kind = 'calls'`
)
.all() as Array<{ file: string }>
).map((row) => row.file)
);
expect(targets).toEqual(['src/alpha.ts']);
});
it('rolls back edge deletion when requeueing its reference is interrupted', async () => {
write('src/caller.ts', `export function run(): number {\n return pct(1);\n}\n`);
write('src/zeta.ts', `export function pct(n: number): number {\n return n;\n}\n`);
cg = CodeGraph.initSync(testDir, { config: { include: ['**/*.ts'], exclude: [] } });
await cg.indexAll();
const originalEdge = withDb((db) => {
const row = db
.prepare(
`SELECT edge.source, edge.target, edge.kind
FROM edges edge
JOIN nodes source ON source.id = edge.source
JOIN nodes target ON target.id = edge.target
WHERE source.name = 'run'
AND target.name = 'pct'
AND edge.kind = 'calls'`
)
.get() as { source: string; target: string; kind: string };
db.exec(
`CREATE TRIGGER interrupt_pct_requeue
BEFORE INSERT ON unresolved_refs
WHEN NEW.reference_name = 'pct'
BEGIN
SELECT RAISE(ABORT, 'forced rebind interruption');
END;`
);
return `${row.source}|${row.target}|${row.kind}`;
});
write('src/alpha.ts', `export function pct(n: number): number {\n return n * 2;\n}\n`);
await expect(cg.sync()).rejects.toThrow(/forced rebind interruption/);
// A failed requeue leaves the last committed graph answer untouched.
expect(edgeSet().has(originalEdge)).toBe(true);
const queued = withDb(
(db) =>
(
db
.prepare(
`SELECT COUNT(*) AS count
FROM unresolved_refs ref
JOIN nodes source ON source.id = ref.from_node_id
WHERE source.name = 'run' AND ref.reference_name = 'pct'`
)
.get() as { count: number }
).count
);
expect(queued).toBe(0);
});
/**
* The mirror direction: removing a definition narrows the candidate set too,
* so the delta must include names the sync DROPPED, not just names it added.
+15
View File
@@ -3526,6 +3526,21 @@ export class QueryBuilder {
return changed;
}
/**
* Replace resolution edges with their original unresolved references as one
* transaction. If ref insertion fails, the edge deletion is rolled back.
*/
replaceResolutionEdgesWithUnresolvedRefs(
edgeIds: number[],
refs: UnresolvedReference[]
): number {
return this.db.transaction(() => {
const changed = this.deleteEdgesByIds(edgeIds);
this.insertUnresolvedRefsBatch(refs);
return changed;
})();
}
/**
* Distinct node names present in the given files — the symbol names a sync
* pass uses to look up retryable failed refs after those files changed.
+1 -2
View File
@@ -2906,8 +2906,7 @@ export class ExtractionOrchestrator {
// rebind to the same target is a clean no-op, but leaving the old row in
// place for a rebind ELSEWHERE would keep both, turning drift into
// duplication.
this.queries.deleteEdgesByIds(edgeIds);
this.queries.insertUnresolvedRefsBatch(refs);
this.queries.replaceResolutionEdgesWithUnresolvedRefs(edgeIds, refs);
return refs.length;
}