sync: harden standalone mcp and chunk recovery

This commit is contained in:
“BeeRad”
2026-04-16 09:53:56 +10:00
parent 097c4c61bb
commit c2f880d957
15 changed files with 222 additions and 279 deletions
+22 -17
View File
@@ -260,33 +260,40 @@ export class ChunkService {
const startTime = Date.now();
const vectorString = `[${queryEmbedding.join(',')}]`;
// When searching specific nodes, first get their chunk_ids to scope the vector search
if (nodeIds && nodeIds.length > 0) {
// Get chunk IDs for the target nodes
const chunkIdsQuery = `SELECT id FROM chunks WHERE node_id IN (${nodeIds.map(() => '?').join(',')})`;
const chunkIdsResult = sqlite.query<{id: number}>(chunkIdsQuery, nodeIds);
const chunkIds = chunkIdsResult.rows.map(r => r.id);
const vectorLimit = Math.max(matchCount * 10, 50);
if (chunkIds.length === 0) {
// vec0 requires the knn constraint to live directly on the vec table query.
// A previous change pushed node-scoping into that WHERE clause in a way vec0 rejects,
// which made every node-scoped vector search throw and silently fall back to text.
if (nodeIds && nodeIds.length > 0) {
const chunkCountQuery = `SELECT COUNT(*) AS count FROM chunks WHERE node_id IN (${nodeIds.map(() => '?').join(',')})`;
const chunkCountResult = sqlite.query<{ count: number }>(chunkCountQuery, nodeIds);
const chunkCount = Number(chunkCountResult.rows[0]?.count ?? 0);
if (chunkCount === 0) {
console.log(`🔍 Node-scoped search: no chunks found for nodes ${nodeIds.join(', ')}`);
return [];
}
console.log(`🔍 Node-scoped search: ${chunkIds.length} chunks in nodes ${nodeIds.join(', ')}`);
console.log(`🔍 Node-scoped search: ${chunkCount} chunks in nodes ${nodeIds.join(', ')}`);
// Search ONLY within those chunks using rowid filter
const query = `
let query = `
SELECT c.*, (1.0 / (1.0 + v.distance)) AS similarity
FROM vec_chunks v
FROM (
SELECT chunk_id, distance
FROM vec_chunks
WHERE embedding MATCH ?
ORDER BY distance
LIMIT ?
) v
JOIN chunks c ON c.id = v.chunk_id
WHERE v.embedding MATCH ?
AND v.chunk_id IN (${chunkIds.map(() => '?').join(',')})
WHERE c.node_id IN (${nodeIds.map(() => '?').join(',')})
AND (1.0 / (1.0 + v.distance)) >= ?
ORDER BY v.distance
ORDER BY similarity DESC
LIMIT ?
`;
const params = [vectorString, ...chunkIds, similarityThreshold, matchCount];
const params = [vectorString, vectorLimit, ...nodeIds, similarityThreshold, matchCount];
const result = sqlite.query<Chunk & { similarity: number }>(query, params);
const searchTime = Date.now() - startTime;
@@ -299,8 +306,6 @@ export class ChunkService {
}
// Global search (no node filter)
const vectorLimit = Math.max(matchCount * 10, 50);
const query = `
WITH vector_results AS (
SELECT chunk_id, distance
+19 -1
View File
@@ -1238,12 +1238,20 @@ class SQLiteClient {
createSql: string,
triggerSql: string,
rebuildSql: string,
isStale: (sql: string) => boolean,
) => {
const existing = this.db.prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name=?").get(tableName) as { sql?: string } | undefined;
const existingSql = (existing?.sql || '').toLowerCase().replace(/\s+/g, ' ').trim();
const missing = !existingSql;
const stale = !missing && isStale(existingSql);
if (!existing?.sql) {
if (missing) {
this.db.exec(createSql);
this.db.exec(rebuildSql);
} else if (stale) {
console.warn(
`[SQLiteFTS] ${tableName} schema is stale. Skipping destructive startup rebuild; use the offline repair/rebuild path instead.`
);
}
this.db.exec(triggerSql);
@@ -1272,6 +1280,12 @@ class SQLiteClient {
END;
`,
"INSERT INTO nodes_fts(nodes_fts) VALUES('rebuild');",
(sql) =>
!/\bfts5\(\s*title\s*,\s*source\s*,\s*description\b/.test(sql) ||
!sql.includes("content='nodes'") ||
!sql.includes("content_rowid='id'") ||
/\bnotes\b/.test(sql) ||
/\btitle\s*,\s*content\b/.test(sql),
);
ensureFts(
@@ -1297,6 +1311,10 @@ class SQLiteClient {
END;
`,
"INSERT INTO chunks_fts(chunks_fts) VALUES('rebuild');",
(sql) =>
!/\bfts5\(\s*text\b/.test(sql) ||
!sql.includes("content='chunks'") ||
!sql.includes("content_rowid='id'"),
);
} catch (error) {
console.warn('Failed to ensure FTS tables:', error);
+32 -6
View File
@@ -9,6 +9,7 @@ interface AutoEmbedTask {
}
const DEFAULT_COOLDOWN_MS = 2 * 60 * 1000; // 2 minutes between automatic runs per node
const RECOVERY_INTERVAL_MS = 60 * 1000;
export class AutoEmbedQueue {
private readonly queue: number[] = [];
@@ -88,10 +89,17 @@ export class AutoEmbedQueue {
}
private async executeTask(task: AutoEmbedTask) {
const sqlite = getSQLiteClient();
const integrity = sqlite.getIntegrityReport();
if (!sqlite.canUseFtsTable('chunks')) {
const integrity = getSQLiteClient().getIntegrityReport();
if (!integrity.ftsTables.chunks) {
console.warn('[AutoEmbedQueue] Skipping chunk write because chunks FTS is degraded:', integrity.summary);
try {
const node = await nodeService.getNodeById(task.nodeId);
if (node && node.chunk_status !== 'chunked' && node.chunk_status !== 'error') {
await nodeService.updateNode(task.nodeId, { chunk_status: 'error' });
}
} catch (error) {
console.warn('[AutoEmbedQueue] Failed to mark node as error while chunks FTS is degraded', task.nodeId, error);
}
return;
}
@@ -121,12 +129,30 @@ export class AutoEmbedQueue {
declare global {
// eslint-disable-next-line no-var
var autoEmbedQueue: AutoEmbedQueue | undefined;
// eslint-disable-next-line no-var
var autoEmbedRecoveryStarted: boolean | undefined;
// eslint-disable-next-line no-var
var autoEmbedRecoveryTimer: ReturnType<typeof setInterval> | undefined;
}
export const autoEmbedQueue = globalThis.autoEmbedQueue ?? new AutoEmbedQueue();
if (!globalThis.autoEmbedQueue) {
globalThis.autoEmbedQueue = autoEmbedQueue;
autoEmbedQueue.recoverStuckNodes().catch(error => {
console.error('[AutoEmbedQueue] Startup recovery failed', error);
});
}
export function startAutoEmbedRecovery(): void {
if (globalThis.autoEmbedRecoveryStarted) {
return;
}
globalThis.autoEmbedRecoveryStarted = true;
const runRecovery = () => {
autoEmbedQueue.recoverStuckNodes().catch(error => {
console.error('[AutoEmbedQueue] Startup recovery failed', error);
});
};
runRecovery();
globalThis.autoEmbedRecoveryTimer = setInterval(runRecovery, RECOVERY_INTERVAL_MS);
globalThis.autoEmbedRecoveryTimer.unref?.();
}