fix: sequential enrichment queries + stale data detection

Fixes three related issues that cause SIGSEGV crashes and stale data:

1. Impact enrichment queries (Promise.all → sequential await)
   The impact() method ran 3 enrichment queries concurrently via
   Promise.all against the same LadybugDB connection pool. On arm64
   macOS, concurrent native DB access triggers SIGSEGV. Changed to
   sequential await. Also caps IN-clause to 100 IDs to prevent
   oversized queries. (#285, #290, #292)

2. Silence stdout during query execution
   silenceStdout()/restoreStdout() only wrapped createConnection() and
   initLbug(). Now also wraps executeQuery() and executeParameterized()
   to prevent native stdout writes from corrupting the MCP stdio
   stream during all DB operations. (#285)

3. Stale data after re-index
   ensureInitialized() checked pool existence but never verified whether
   the underlying index was rebuilt. Now reads meta.json's indexedAt
   timestamp on each call and closes/re-opens the pool when the index
   has changed. (#297)
This commit is contained in:
hiromima 2026-03-20 13:33:20 +09:00
parent c14a78a341
commit 999fbf5b11
2 changed files with 54 additions and 27 deletions

View file

@ -458,12 +458,14 @@ export const executeQuery = async (repoId: string, cypher: string): Promise<any[
entry.lastUsed = Date.now();
const conn = await checkout(entry);
silenceStdout();
try {
const queryResult = await withTimeout(conn.query(cypher), QUERY_TIMEOUT_MS, 'Query');
const result = Array.isArray(queryResult) ? queryResult[0] : queryResult;
const rows = await result.getAll();
return rows;
} finally {
restoreStdout();
checkin(entry, conn);
}
};
@ -485,6 +487,7 @@ export const executeParameterized = async (
entry.lastUsed = Date.now();
const conn = await checkout(entry);
silenceStdout();
try {
const stmt = await withTimeout(conn.prepare(cypher), QUERY_TIMEOUT_MS, 'Prepare');
if (!stmt.isSuccess()) {
@ -496,6 +499,7 @@ export const executeParameterized = async (
const rows = await result.getAll();
return rows;
} finally {
restoreStdout();
checkin(entry, conn);
}
};

View file

@ -246,12 +246,30 @@ export class LocalBackend {
// ─── Lazy LadybugDB Init ────────────────────────────────────────────
private async ensureInitialized(repoId: string): Promise<void> {
// Always check the actual pool — the idle timer may have evicted the connection
if (this.initializedRepos.has(repoId) && isLbugReady(repoId)) return;
const handle = this.repos.get(repoId);
if (!handle) throw new Error(`Unknown repo: ${repoId}`);
// Check if the index was rebuilt since we opened the connection (#297).
// Read meta.json's indexedAt and compare to the cached value — if it
// changed, close the stale pool and re-initialize with the fresh index.
if (this.initializedRepos.has(repoId) && isLbugReady(repoId)) {
try {
const metaPath = path.join(handle.storagePath, 'meta.json');
const metaRaw = await fs.readFile(metaPath, 'utf-8');
const meta = JSON.parse(metaRaw);
if (meta.indexedAt && meta.indexedAt !== handle.indexedAt) {
// Index was rebuilt — close stale connection and re-init
await closeLbug(repoId);
this.initializedRepos.delete(repoId);
handle.indexedAt = meta.indexedAt;
} else {
return; // Pool is current
}
} catch {
return; // Can't read meta — assume pool is fine
}
}
try {
await initLbug(repoId, handle.lbugPath);
this.initializedRepos.add(repoId);
@ -1438,31 +1456,36 @@ export class LocalBackend {
let affectedModules: any[] = [];
if (impacted.length > 0) {
const allIds = impacted.map(i => `'${i.id.replace(/'/g, "''")}'`).join(', ');
const d1Ids = (grouped[1] || []).map((i: any) => `'${i.id.replace(/'/g, "''")}'`).join(', ');
// Cap IN-clause to 100 IDs to prevent oversized queries that crash
// the native DB engine on arm64 macOS (#292)
const cappedImpacted = impacted.slice(0, 100);
const allIds = cappedImpacted.map(i => `'${i.id.replace(/'/g, "''")}'`).join(', ');
const d1Items = (grouped[1] || []).slice(0, 100);
const d1Ids = d1Items.map((i: any) => `'${i.id.replace(/'/g, "''")}'`).join(', ');
// Affected processes: which execution flows are broken and at which step
const [processRows, moduleRows, directModuleRows] = await Promise.all([
executeQuery(repo.id, `
MATCH (s)-[r:CodeRelation {type: 'STEP_IN_PROCESS'}]->(p:Process)
WHERE s.id IN [${allIds}]
RETURN p.heuristicLabel AS name, COUNT(DISTINCT s.id) AS hits, MIN(r.step) AS minStep, p.stepCount AS stepCount
ORDER BY hits DESC
LIMIT 20
`).catch(() => []),
executeQuery(repo.id, `
MATCH (s)-[:CodeRelation {type: 'MEMBER_OF'}]->(c:Community)
WHERE s.id IN [${allIds}]
RETURN c.heuristicLabel AS name, COUNT(DISTINCT s.id) AS hits
ORDER BY hits DESC
LIMIT 20
`).catch(() => []),
d1Ids ? executeQuery(repo.id, `
MATCH (s)-[:CodeRelation {type: 'MEMBER_OF'}]->(c:Community)
WHERE s.id IN [${d1Ids}]
RETURN DISTINCT c.heuristicLabel AS name
`).catch(() => []) : Promise.resolve([]),
]);
// Run enrichment queries sequentially to avoid concurrent native DB
// access that causes SIGSEGV on arm64 macOS (#285, #290, #292)
const processRows = await executeQuery(repo.id, `
MATCH (s)-[r:CodeRelation {type: 'STEP_IN_PROCESS'}]->(p:Process)
WHERE s.id IN [${allIds}]
RETURN p.heuristicLabel AS name, COUNT(DISTINCT s.id) AS hits, MIN(r.step) AS minStep, p.stepCount AS stepCount
ORDER BY hits DESC
LIMIT 20
`).catch(() => []);
const moduleRows = await executeQuery(repo.id, `
MATCH (s)-[:CodeRelation {type: 'MEMBER_OF'}]->(c:Community)
WHERE s.id IN [${allIds}]
RETURN c.heuristicLabel AS name, COUNT(DISTINCT s.id) AS hits
ORDER BY hits DESC
LIMIT 20
`).catch(() => []);
const directModuleRows = d1Ids
? await executeQuery(repo.id, `
MATCH (s)-[:CodeRelation {type: 'MEMBER_OF'}]->(c:Community)
WHERE s.id IN [${d1Ids}]
RETURN DISTINCT c.heuristicLabel AS name
`).catch(() => [])
: [];
affectedProcesses = processRows.map((r: any) => ({
name: r.name || r[0],