mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-10-29 14:49:36 +08:00
fix(sync): step aside for a rebuild that has recreated the file but not locked it yet
`codegraph index` recreates the database, then takes the write lock inside indexAll. With the watcher now following a replaced file, a sync landing in that gap reopened the empty file and started a full reconcile, holding the lock the rebuild was about to request — `codegraph index` could then fail with "another process may be indexing". Review flagged the gap; this closes it. After following a replaced file, a sync that finds no index_state yet (the rebuild has not started indexing) and a file written within the last two minutes returns the lock-busy shape so the watcher retries; the first sync after that reconciles the whole tree once, then syncs go back to scoped. The time bound keeps a rebuild that died before indexing from parking the watcher forever. New test: fails without the guard, passes with it; 136 related tests pass. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
committed by
Forge
co-authored by
Claude Opus 5.5
parent
d575529171
commit
5570e5d0b7
@@ -83,6 +83,38 @@ describe('live sync after the index is rebuilt by another process (#1902)', () =
|
||||
}
|
||||
});
|
||||
|
||||
posixOnly('a sync in the gap between recreate and indexAll steps aside, then reconciles in full', async () => {
|
||||
const server = CodeGraph.initSync(root);
|
||||
await server.indexAll();
|
||||
try {
|
||||
// `codegraph index`: the file is recreated first, the write lock is taken later by indexAll.
|
||||
const rebuilder = await CodeGraph.recreate(root);
|
||||
fs.appendFileSync(path.join(root, 'src', 'a.ts'), 'export function gamma() { return 3; }\n');
|
||||
// The server's sync lands in the gap: it must not claim the lock for a full reconcile of the empty file.
|
||||
const gap = await server.sync({ paths: ['src/a.ts'] });
|
||||
expect(gap.filesChecked).toBe(0);
|
||||
// So the rebuild still gets its lock and completes.
|
||||
const built = await rebuilder.indexAll();
|
||||
expect(built.success).toBe(true);
|
||||
rebuilder.close();
|
||||
// An edit the rebuild did not see, made after it finished.
|
||||
fs.appendFileSync(path.join(root, 'src', 'b.ts'), 'export function delta() { return 4; }\n');
|
||||
// The next (scoped, unrelated) sync is widened to the whole tree once.
|
||||
fs.writeFileSync(path.join(root, 'src', 'c.ts'), 'export function echo() { return 5; }\n');
|
||||
const after = await server.sync({ paths: ['src/c.ts'] });
|
||||
expect(after.filesChecked).toBeGreaterThan(1);
|
||||
expect(await onDiskHas(root, 'gamma')).toBe(true);
|
||||
expect(await onDiskHas(root, 'delta')).toBe(true);
|
||||
expect(await onDiskHas(root, 'echo')).toBe(true);
|
||||
// And only once: the following scoped sync stays scoped.
|
||||
fs.appendFileSync(path.join(root, 'src', 'c.ts'), 'export function foxtrot() { return 6; }\n');
|
||||
const scoped = await server.sync({ paths: ['src/c.ts'] });
|
||||
expect(scoped.filesChecked).toBe(1);
|
||||
} finally {
|
||||
server.close();
|
||||
}
|
||||
});
|
||||
|
||||
posixOnly('a scoped sync that finds the database replaced reconciles the whole tree', async () => {
|
||||
const server = CodeGraph.initSync(root);
|
||||
await server.indexAll();
|
||||
|
||||
+35
-1
@@ -265,6 +265,25 @@ export class CodeGraph {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set when this instance followed a database replaced on disk (#1902): the
|
||||
* next sync that can run reconciles the whole tree, because whatever the old
|
||||
* handle absorbed since the rebuild never reached the new file.
|
||||
*/
|
||||
private pendingFullReconcile = false;
|
||||
|
||||
/** How long a recreated, not-yet-indexed database is treated as a rebuild in progress. */
|
||||
private static readonly RECREATE_GRACE_MS = 120_000;
|
||||
|
||||
/** The database file at the path was written within the recreate grace window. */
|
||||
private isFreshlyRecreated(): boolean {
|
||||
try {
|
||||
return Date.now() - fs.statSync(getDatabasePath(this.projectRoot)).mtimeMs < CodeGraph.RECREATE_GRACE_MS;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Heal a stale database handle in place. If `.codegraph/` was removed and
|
||||
* recreated at the SAME path while this instance held the DB open — a git
|
||||
@@ -1091,11 +1110,26 @@ export class CodeGraph {
|
||||
// If the reopen fails (the rebuild is mid-way), report the lock-busy
|
||||
// shape so the watcher keeps its pending files and retries.
|
||||
try {
|
||||
if (this.reopenReplacedDatabase()) options = { ...options, paths: undefined };
|
||||
if (this.reopenReplacedDatabase()) this.pendingFullReconcile = true;
|
||||
} catch {
|
||||
this.fileLock.release();
|
||||
return { filesChecked: 0, filesAdded: 0, filesModified: 0, filesRemoved: 0, nodesUpdated: 0, durationMs: 0 };
|
||||
}
|
||||
if (this.pendingFullReconcile) {
|
||||
// `codegraph index` recreates the file, THEN takes the write lock in
|
||||
// indexAll. A sync landing in that gap would otherwise run a full
|
||||
// reconcile of the empty file and hold the lock the rebuild is about
|
||||
// to ask for. A fresh file with no index_state yet is that rebuild:
|
||||
// step aside (lock-busy shape, the watcher retries) and reconcile in
|
||||
// full once it is done. Bounded, so a rebuild that died before
|
||||
// indexing does not park the watcher forever.
|
||||
if (this.getIndexState() === null && this.isFreshlyRecreated()) {
|
||||
this.fileLock.release();
|
||||
return { filesChecked: 0, filesAdded: 0, filesModified: 0, filesRemoved: 0, nodesUpdated: 0, durationMs: 0 };
|
||||
}
|
||||
this.pendingFullReconcile = false;
|
||||
options = { ...options, paths: undefined };
|
||||
}
|
||||
// Defer WAL auto-checkpointing for the whole incremental run, exactly
|
||||
// as indexAll does for the bulk path (#1231): sync's store loop and its
|
||||
// resolution passes churn the same FTS + secondary-index hot pages, and
|
||||
|
||||
Reference in New Issue
Block a user