Skip to content

Commit 7575dcc

Browse files
author
Forge
committed
Bulk rebuild candidate indexes and retain concurrent config edits
1 parent f7793a8 commit 7575dcc

6 files changed

Lines changed: 51 additions & 15 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
1717

1818
### Extension preview
1919

20-
- Reconcile global semantic-extension links after sync or explicit file indexing with an atomic candidate rebuild. Failed passes preserve the working graph. Reuse bounded core parse results on warm project instances while rerunning all framework hooks, global resolution and semantic passes; cold or disabled reuse retains full parsing. Reject observed source changes before commit and reclaim interrupted graph-only candidates. Rebuild text search in bulk inside the atomic replacement transaction.
20+
- Reconcile global semantic-extension links after sync or explicit file indexing with an atomic candidate rebuild. Failed passes preserve the working graph. Reuse bounded core parse results on warm project instances while rerunning all framework hooks, global resolution and semantic passes; cold or disabled reuse retains full parsing. Reject observed source changes before commit and reclaim interrupted graph-only candidates. Rebuild text search and built-in secondary indexes in bulk inside the atomic replacement transaction.
2121

2222
- Prepare immutable Drupal extension 0.1.1: ignore unrelated static receivers and commented/quoted declarations, and resolve both explicit Symfony event argument orders. Preserve the reviewed 0.1.0 artifact and policy.
2323
- Keep installed and selected marketplace versions distinct while compatibility metadata refreshes; pending requests no longer display an incompatible catalog version as installed.

‎__tests__/extension-sync.test.ts‎

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,13 +48,13 @@ it('reuses core parsing while rerunning global hooks, rejects source races, isol
4848
const author = path.join(root, 'author'); createExtensionProject(author, 'global-fixture');
4949
const manifestPath = path.join(author, 'package.json'), manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
5050
manifest.codegraph.capabilities = ['frameworks', 'synthPasses']; fs.writeFileSync(manifestPath, JSON.stringify(manifest));
51-
fs.writeFileSync(path.join(author, 'index.cjs'), `module.exports = ({projectRoot}) => ({
51+
fs.writeFileSync(path.join(author, 'index.cjs'), `module.exports = ({projectRoot,options}) => ({
5252
frameworks:[{name:'global-framework',languages:['python'],detect:()=>true,resolve:()=>null,
5353
extract(file){ if(file!=='handlers.py')return {nodes:[],references:[]};
5454
const name=require('fs').readFileSync(require('path').join(projectRoot,'binding.py'),'utf8').trim();
5555
return {nodes:[{id:'plugin:global-fixture:route',kind:'route',name,qualifiedName:name,filePath:file,language:'python',startLine:1,endLine:1,startColumn:0,endColumn:1,metadata:{}}],references:[]}; }}],
5656
synthPasses:[{name:'bindings',languages:['python'],run(ctx){
57-
const name=(ctx.readFile('binding.py')||'').trim(); if(name==='FAIL')throw Error('real pass failed');
57+
const name=options.target||(ctx.readFile('binding.py')||'').trim(); if(name==='FAIL')throw Error('real pass failed');
5858
const from=ctx.getNodesByName('dispatch').filter(n=>n.kind==='function'),to=ctx.getNodesByName(name).filter(n=>n.kind==='function');if(from.length!==1||to.length!==1)return [];
5959
return [{source:from[0].id,target:to[0].id,kind:'calls',line:1,metadata:{label:'Global binding'}}]; }}]
6060
});`);
@@ -91,6 +91,18 @@ it('reuses core parsing while rerunning global hooks, rejects source races, isol
9191
expect(edges(g)).toEqual([['dispatch','cancel']]);
9292
fs.writeFileSync(binding,'receipt'); await g.sync(); expect(edges(g)).toEqual([['dispatch','receipt']]);
9393
expect(edges(graphs[1])).toEqual([['dispatch','cancel']]);
94+
// A config edit after the final source observation must remain dirty. The
95+
// committed stamp must describe the evaluated options, not the later file.
96+
const configFile = path.join(project, 'codegraph.json'), config = JSON.parse(fs.readFileSync(configFile, 'utf8'));
97+
const editDuringCopy = (event: any) => { if (event.projectRoot === project && event.phase === 'copy_started') {
98+
config.plugins[0].options = { target: 'cancel' }; fs.writeFileSync(configFile, JSON.stringify(config));
99+
}};
100+
diagnostics.subscribe(editDuringCopy);
101+
try { await g.indexFiles(['handlers.py']); } finally { diagnostics.unsubscribe(editDuringCopy); }
102+
expect(edges(g)).toEqual([['dispatch','receipt']]);
103+
expect(JSON.parse(fs.readFileSync(configFile,'utf8')).plugins[0].options.target).toBe('cancel');
104+
await g.sync(); expect(edges(g)).toEqual([['dispatch','cancel']]);
105+
config.plugins[0].options = {}; fs.writeFileSync(configFile,JSON.stringify(config)); await g.sync();
94106
const before = JSON.stringify([edges(g),g.getNodesByKind('route')]);
95107
const fresh = await CodeGraph.open(project); graphs.push(fresh); await fresh.refreshPluginIndex();
96108
expect(JSON.stringify([edges(fresh),fresh.getNodesByKind('route')])).toBe(before);

‎docs/design/semantic-update-reuse.md‎

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -30,19 +30,21 @@ Before/after source stamps cover the scanned indexed files plus root config and
3030
ignore rules. An observed change aborts before commit and preserves the working
3131
graph. This does not lock the filesystem: arbitrary external plugin I/O or a
3232
write after the final observation is not a certified snapshot. Retry after edits
33-
settle. Cache entries are content-keyed, so a failed candidate may safely retain
33+
settle. The committed plugin fingerprint is frozen at registry load, so a config
34+
edit during the subsequent SQLite copy stays dirty for the next sync instead of
35+
incorrectly labelling an old graph as matching new options. Cache entries are content-keyed, so a failed candidate may safely retain
3436
unchanged core parses but never graph or plugin output.
3537

3638
Graph-only candidate records contain a checksummed UUID and diagnostic PID.
3739
The project SQLite coordinator is authoritative, independent of PID reuse.
3840
Fresh opens reclaim the exact orphaned stage after process death; an invalid
3941
record blocks recovery without deleting files. During a live graph-only update,
4042
readers can use the old committed graph. The existing SQLite graph transaction
41-
still determines old/new visibility. FTS triggers are suspended and rebuilt
43+
still determines old/new visibility. Built-in non-unique graph secondary indexes and FTS triggers are suspended and rebuilt
4244
inside that same transaction using the existing bulk-load API, avoiding per-row
43-
FTS work during replacement. A kill rolls back the trigger schema and FTS data
45+
index maintenance during replacement. A kill rolls back the secondary/trigger schema and FTS data
4446
with the graph; readers never see a committed missing-trigger window.
45-
`CODEGRAPH_NO_SEMANTIC_BULK_FTS=1` retains the previous trigger path. A commit marker invalidates long-lived
47+
`CODEGRAPH_NO_SEMANTIC_BULK=1` retains the previous trigger path. A commit marker invalidates long-lived
4648
reader caches. Managed config/trust transactions retain their exclusive guard.
4749

4850
Measurements and correctness/recovery coverage will be linked after validation.

‎scripts/validation/semantic-update-recovery.cjs‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ const {CodeGraph,createExtensionProject,packExtension}=require('../../dist');con
55
const {DatabaseSync}=require('node:sqlite');
66
Object.assign(process.env,{CODEGRAPH_TELEMETRY:'0',CODEGRAPH_PARSE_WORKERS:'2',CODEGRAPH_RESOLVE_WORKERS:'2',CODEGRAPH_PARALLEL_RESOLVE_MIN:'0'});
77
const phases=['candidate_ready','before_commit','copy_started','in_commit','committed'];
8+
function schema(root){const db=new DatabaseSync(path.join(root,'.codegraph/codegraph.db'),{readOnly:true});try{return db.prepare("SELECT name,sql FROM sqlite_master WHERE type IN ('index','trigger') AND sql IS NOT NULL ORDER BY name").all().map(r=>[r.name,r.sql])}finally{db.close()}}
89
function ftsAdded(root){const db=new DatabaseSync(path.join(root,'.codegraph/codegraph.db'),{readOnly:true});try{return db.prepare("SELECT count(*) n FROM nodes_fts WHERE nodes_fts MATCH 'added_function'").get().n}finally{db.close()}}
910
function snapshot(root){const db=new DatabaseSync(path.join(root,'.codegraph/codegraph.db'),{readOnly:true});try{return db.prepare("select a.name source,b.name target from edges e join nodes a on a.id=e.source join nodes b on b.id=e.target where e.metadata like '%\"synthesizedBy\":\"kill-fixture\"%' order by a.name,b.name").all().map(r=>[r.source,r.target]);}finally{db.close()}}
1011
if(process.argv[2]==='--child'){
@@ -25,15 +26,15 @@ if(process.argv[2]==='--child'){
2526
try{
2627
const author=path.join(lab,'author');createExtensionProject(author,'kill-fixture');const manifest=JSON.parse(fs.readFileSync(path.join(author,'package.json'),'utf8'));manifest.codegraph.capabilities=['synthPasses'];fs.writeFileSync(path.join(author,'package.json'),JSON.stringify(manifest));
2728
fs.writeFileSync(path.join(author,'index.cjs'),`module.exports=()=>({synthPasses:[{name:'binding',languages:['python'],run(ctx){const name=(ctx.readFile('binding.py')||'').trim();const a=ctx.getNodesByName('dispatch'),b=ctx.getNodesByName(name);if(a.length!==1||b.length!==1)return [];return [{source:a[0].id,target:b[0].id,kind:'calls',metadata:{label:'fixture'}}]}}]});`);const bytes=packExtension(author);
28-
for(const phase of phases){const root=path.join(lab,phase);fs.mkdirSync(root);fs.writeFileSync(path.join(root,'handlers.py'),'def dispatch():\n missing()\ndef receipt():\n pass\ndef cancel():\n pass\n');fs.writeFileSync(path.join(root,'binding.py'),'receipt');await new ExtensionManager(root).install({bytes});const config=fs.readFileSync(path.join(root,'codegraph.json'),'utf8');
29+
for(const phase of phases){const root=path.join(lab,phase);fs.mkdirSync(root);fs.writeFileSync(path.join(root,'handlers.py'),'def dispatch():\n missing()\ndef receipt():\n pass\ndef cancel():\n pass\n');fs.writeFileSync(path.join(root,'binding.py'),'receipt');await new ExtensionManager(root).install({bytes});const custom=new DatabaseSync(path.join(root,'.codegraph/codegraph.db'));custom.exec('CREATE INDEX user_fixture_name ON nodes(name)');custom.close();const originalSchema=schema(root);const config=fs.readFileSync(path.join(root,'codegraph.json'),'utf8');
2930
const reader=await CodeGraph.open(root);assert.deepEqual(snapshot(root),[['dispatch','receipt']]);reader.getNodesByKind('function');
3031
const c=child(root,'update',phase);let timer;try{await Promise.race([c.reached,c.done.then(r=>{throw Error('child ended '+r.stderr)}),new Promise((_,r)=>timer=setTimeout(()=>r(Error('timeout '+phase)),90000))])}finally{clearTimeout(timer)}
31-
const expected=phase==='committed'?'cancel':'receipt';assert.deepEqual(snapshot(root),[['dispatch',expected]]);assert.ok(reader.getStats().nodeCount>0);assert.equal(ftsAdded(root),phase==='committed'?1:0);
32+
const expected=phase==='committed'?'cancel':'receipt';assert.deepEqual(snapshot(root),[['dispatch',expected]]);assert.ok(reader.getStats().nodeCount>0);assert.equal(ftsAdded(root),phase==='committed'?1:0);assert.deepEqual(schema(root),originalSchema);
3233
const concurrent=await child(root,'retry').done;assert.notEqual(concurrent.exit,0);assert.match(concurrent.stderr,/operation is active/);
3334
assert.equal(c.p.kill('SIGKILL'),true);const killed=await c.done;assert.ok(killed.signal==='SIGKILL'||(process.platform==='win32'&&killed.exit!==0));
34-
fs.writeFileSync(path.join(root,'keep.txt'),'unrelated user file');const inspect=await child(root,'inspect').done;assert.equal(inspect.exit,0,inspect.stderr);assert.deepEqual(snapshot(root),[['dispatch',expected]]);assert.equal(ftsAdded(root),phase==='committed'?1:0);
35+
fs.writeFileSync(path.join(root,'keep.txt'),'unrelated user file');const inspect=await child(root,'inspect').done;assert.equal(inspect.exit,0,inspect.stderr);assert.deepEqual(snapshot(root),[['dispatch',expected]]);assert.equal(ftsAdded(root),phase==='committed'?1:0);assert.deepEqual(schema(root),originalSchema);
3536
assert.equal(fs.existsSync(path.join(root,'.codegraph/plugins/candidate.json')),false);assert.deepEqual(fs.readdirSync(path.join(root,'.codegraph')).filter(f=>f.startsWith('extension-stage-')),[]);
36-
assert.equal((await child(root,'inspect').done).exit,0);assert.equal((await child(root,'retry').done).exit,0);assert.deepEqual(snapshot(root),[['dispatch','cancel']]);assert.equal(ftsAdded(root),1);
37+
assert.equal((await child(root,'inspect').done).exit,0);assert.equal((await child(root,'retry').done).exit,0);assert.deepEqual(snapshot(root),[['dispatch','cancel']]);assert.equal(ftsAdded(root),1);assert.deepEqual(schema(root),originalSchema);
3738
const funcs=reader.getNodesByKind('function');const names=new Map(funcs.map(n=>[n.id,n.name]));assert.deepEqual(reader.getOutgoingEdgesFrom(funcs.map(n=>n.id)).filter(e=>e.metadata?.synthesizedBy==='kill-fixture').map(e=>[names.get(e.source),names.get(e.target)]),[['dispatch','cancel']]);reader.close();
3839
assert.equal(fs.readFileSync(path.join(root,'codegraph.json'),'utf8'),config);assert.equal(fs.readFileSync(path.join(root,'keep.txt'),'utf8'),'unrelated user file');rows.push({phase,passed:true,edgesAfterKill:expected,concurrentExcluded:true,repeatedRecovery:true,readerFresh:true});save();console.log('PASS',phase);
3940
}

‎src/db/index.ts‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -303,6 +303,20 @@ export class DatabaseConnection {
303303
await this.endBulkEdgeLoad();
304304
}
305305

306+
/** @internal Synchronous restoration for a whole-graph replacement transaction.
307+
* The caller deletes ALL graph rows, so no mid-load queries need secondary
308+
* indexes. Unlike ordinary async indexing, no yield may escape the enclosing
309+
* SQLite transaction. Primary/unique constraints and user indexes stay intact.
310+
*/
311+
endAtomicBulkParseLoad(): void {
312+
const schema = fs.readFileSync(path.join(__dirname, 'schema.sql'), 'utf-8');
313+
for (const index of [...DatabaseConnection.BULK_PARSE_INDEX_NAMES, ...DatabaseConnection.BULK_EDGE_INDEX_NAMES]) {
314+
const statement = schema.match(new RegExp(`CREATE INDEX IF NOT EXISTS ${index}\\b[^;]*;`));
315+
if (!statement) throw new Error(`schema.sql: index ${index} not found for atomic bulk recreation`);
316+
this.db.exec(statement[0]);
317+
}
318+
}
319+
306320
/**
307321
* unresolved_refs secondary indexes NOT read by the batched resolution
308322
* loop. The loop pages pending refs by keyset (`status='pending' AND id>?`

‎src/index.ts‎

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -567,7 +567,12 @@ export class CodeGraph {
567567
let candidate: CodeGraph | undefined;
568568
let attached = false;
569569
try {
570+
const configFile = path.join(this.projectRoot, 'codegraph.json');
571+
const readConfig = () => fs.existsSync(configFile) ? fs.readFileSync(configFile, 'utf8') : null;
572+
const configAtLoad = readConfig();
570573
const plugins = await loadPlugins(this.projectRoot);
574+
const activatedStamp = this.extensionStamp(plugins);
575+
if (readConfig() !== configAtLoad) throw new Error('Project configuration changed while loading extensions; previous graph retained. Retry the update.');
571576
if (plugins.diagnostics.some(d => d.state !== 'loaded')) throw new Error(plugins.diagnostics.map(d => `${d.id}: ${d.message ?? d.state}`).join('; '));
572577
const inputs = plugins.synthPasses.length ? this.semanticInputStamp() : undefined;
573578
if (!options.extensionTransactionId) { beginGraphCandidate(this.projectRoot, candidateId); recorded = true; }
@@ -597,16 +602,18 @@ export class CodeGraph {
597602
// Candidate replacement is one bulk load. Keep the FTS trigger window
598603
// INSIDE this SQLite transaction so a kill rolls back graph and FTS
599604
// schema together; readers keep the prior committed index.
600-
const bulkFts = process.env.CODEGRAPH_NO_SEMANTIC_BULK_FTS !== '1';
601-
if (bulkFts) this.db.beginBulkNodeLoad();
605+
const bulkGraphIndexes = process.env.CODEGRAPH_NO_SEMANTIC_BULK !== '1';
606+
if (bulkGraphIndexes) { this.db.beginBulkNodeLoad(); this.db.beginBulkParseLoad(); }
602607
channel('codegraph.semantic.update').publish({ projectRoot: this.projectRoot, phase: 'copy_started' });
603608
this.queries.clear();
604609
for (const table of ['files', 'nodes', 'edges', 'unresolved_refs']) {
605610
connection.exec(`INSERT INTO main.${table} SELECT * FROM extension_candidate.${table}`);
606611
}
607-
if (bulkFts) this.db.endBulkNodeLoad();
612+
if (bulkGraphIndexes) { this.db.endAtomicBulkParseLoad(); this.db.endBulkNodeLoad(); }
608613
this.queries.setMetadata('plugins_last_run', JSON.stringify(plugins.diagnostics));
609-
this.queries.setMetadata('indexed_with_plugins', this.extensionStamp(plugins));
614+
// Freeze the evaluated configuration. An edit during the SQLite copy
615+
// must stay dirty for the next sync, never stamp old output as current.
616+
this.queries.setMetadata('indexed_with_plugins', activatedStamp);
610617
this.queries.setMetadata('indexed_with_version', CodeGraphPackageVersion);
611618
this.queries.setMetadata('indexed_with_extraction_version', String(EXTRACTION_VERSION));
612619
this.queries.setMetadata('index_state', 'complete');

0 commit comments

Comments
 (0)