fix: prevent race conditions during vault index rebuild and rate-limit embeddings

- Add AbortController to cancel ongoing indexing before clear/rebuild
- Track currentIndexingPromise to await cancellation before clearing collection
- Batch background indexing (5 files at a time) with 100ms delays between batches
- Skip incremental event-based indexing while a full rebuild is in progress
- Cancel indexing on plugin unload
- Fix nginx CORS headers on OPTIONS preflight responses
This commit is contained in:
2026-05-19 23:29:34 +02:00
parent fae74ade95
commit ce109cf6fb
3 changed files with 170 additions and 37 deletions
+71 -17
View File
@@ -9958,6 +9958,7 @@ var OllamaPlugin = class extends import_obsidian4.Plugin {
} }
// eslint-disable-next-line @typescript-eslint/no-misused-promises // eslint-disable-next-line @typescript-eslint/no-misused-promises
onunload() { onunload() {
this.cancelBackgroundIndexing();
if (this.semanticCache) { if (this.semanticCache) {
void this.semanticCache.clearCache(); void this.semanticCache.clearCache();
} }
@@ -9970,45 +9971,92 @@ var OllamaPlugin = class extends import_obsidian4.Plugin {
await this.saveData(this.settings); await this.saveData(this.settings);
} }
async initializeVaultVectorStore() { async initializeVaultVectorStore() {
this.cancelBackgroundIndexing();
await this.awaitBackgroundIndexing();
this.vaultVectorStore = new VaultVectorStore( this.vaultVectorStore = new VaultVectorStore(
this.settings.ollamaUrl, this.settings.ollamaUrl,
this.settings.vaultIndexConfig this.settings.vaultIndexConfig
); );
try { try {
await this.vaultVectorStore.initialize(); await this.vaultVectorStore.initialize();
void this.backgroundIndexVault().catch((err) => { this.currentIndexingPromise = this.backgroundIndexVault();
void this.currentIndexingPromise.catch((err) => {
Logger.warn(`Background vault indexing failed: ${String(err)}`, "main"); Logger.warn(`Background vault indexing failed: ${String(err)}`, "main");
}).finally(() => {
this.currentIndexingPromise = void 0;
this.indexingAbortController = void 0;
}); });
} catch { } catch {
new import_obsidian4.Notice("Vault vector store initialization failed. Check console for details."); new import_obsidian4.Notice("Vault vector store initialization failed. Check console for details.");
} }
} }
cancelBackgroundIndexing() {
if (this.indexingAbortController) {
this.indexingAbortController.abort();
}
}
async awaitBackgroundIndexing() {
if (this.currentIndexingPromise) {
try {
await this.currentIndexingPromise;
} catch {
}
}
}
async backgroundIndexVault() { async backgroundIndexVault() {
if (!this.vaultVectorStore) return; if (!this.vaultVectorStore) return;
this.indexingAbortController = new AbortController();
const signal = this.indexingAbortController.signal;
const files = this.app.vault.getMarkdownFiles(); const files = this.app.vault.getMarkdownFiles();
Logger.info(`Starting background vault indexing for ${files.length} files...`, "main"); Logger.info(`Starting background vault indexing for ${files.length} files...`, "main");
let indexed = 0; let indexed = 0;
for (const file of files) { const BATCH_SIZE = 5;
try { const DELAY_MS = 100;
const content = await this.app.vault.read(file); for (let i = 0; i < files.length; i += BATCH_SIZE) {
await this.vaultVectorStore.indexFile(file, content); if (signal.aborted) {
indexed++; Logger.info("Vault indexing cancelled.", "main");
} catch (error) { return;
const errorMessage = error instanceof Error ? error.message : String(error); }
Logger.warn(`Failed to index ${file.path}: ${errorMessage}`, "main"); const batch = files.slice(i, i + BATCH_SIZE);
await Promise.all(
batch.map(async (file) => {
if (signal.aborted) return;
try {
const content = await this.app.vault.read(file);
if (signal.aborted) return;
await this.vaultVectorStore.indexFile(file, content);
indexed++;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
Logger.warn(`Failed to index ${file.path}: ${errorMessage}`, "main");
}
})
);
if (i + BATCH_SIZE < files.length) {
await new Promise((resolve) => setTimeout(resolve, DELAY_MS));
} }
} }
this.settings.lastIndexTime = Date.now(); if (!signal.aborted) {
await this.saveSettings(); this.settings.lastIndexTime = Date.now();
Logger.info(`Vault indexing complete: ${indexed}/${files.length} files indexed.`, "main"); await this.saveSettings();
new import_obsidian4.Notice(`Vault index updated: ${indexed} files indexed.`); Logger.info(`Vault indexing complete: ${indexed}/${files.length} files indexed.`, "main");
new import_obsidian4.Notice(`Vault index updated: ${indexed} files indexed.`);
}
} }
async rebuildVaultIndex() { async rebuildVaultIndex() {
this.cancelBackgroundIndexing();
await this.awaitBackgroundIndexing();
if (this.vaultVectorStore) { if (this.vaultVectorStore) {
await this.vaultVectorStore.clearIndex(); await this.vaultVectorStore.clearIndex();
await this.vaultVectorStore.initialize(); await this.vaultVectorStore.initialize();
} }
await this.backgroundIndexVault(); this.currentIndexingPromise = this.backgroundIndexVault();
void this.currentIndexingPromise.catch((err) => {
Logger.warn(`Rebuild vault indexing failed: ${String(err)}`, "main");
}).finally(() => {
this.currentIndexingPromise = void 0;
this.indexingAbortController = void 0;
});
} }
async clearVaultIndex() { async clearVaultIndex() {
if (this.vaultVectorStore) { if (this.vaultVectorStore) {
@@ -10020,7 +10068,9 @@ var OllamaPlugin = class extends import_obsidian4.Plugin {
this.app.vault.on("create", (file) => { this.app.vault.on("create", (file) => {
if (file instanceof import_obsidian4.TFile && file.extension === "md" && this.vaultVectorStore) { if (file instanceof import_obsidian4.TFile && file.extension === "md" && this.vaultVectorStore) {
void this.app.vault.read(file).then((content) => { void this.app.vault.read(file).then((content) => {
void this.vaultVectorStore?.indexFile(file, content); if (!this.currentIndexingPromise) {
void this.vaultVectorStore?.indexFile(file, content);
}
}); });
} }
}) })
@@ -10029,7 +10079,9 @@ var OllamaPlugin = class extends import_obsidian4.Plugin {
this.app.vault.on("modify", (file) => { this.app.vault.on("modify", (file) => {
if (file instanceof import_obsidian4.TFile && file.extension === "md" && this.vaultVectorStore) { if (file instanceof import_obsidian4.TFile && file.extension === "md" && this.vaultVectorStore) {
void this.app.vault.read(file).then((content) => { void this.app.vault.read(file).then((content) => {
void this.vaultVectorStore?.indexFile(file, content); if (!this.currentIndexingPromise) {
void this.vaultVectorStore?.indexFile(file, content);
}
}); });
} }
}) })
@@ -10046,7 +10098,9 @@ var OllamaPlugin = class extends import_obsidian4.Plugin {
if (file instanceof import_obsidian4.TFile && file.extension === "md" && this.vaultVectorStore) { if (file instanceof import_obsidian4.TFile && file.extension === "md" && this.vaultVectorStore) {
void this.vaultVectorStore.deleteFile(oldPath); void this.vaultVectorStore.deleteFile(oldPath);
void this.app.vault.read(file).then((content) => { void this.app.vault.read(file).then((content) => {
void this.vaultVectorStore?.indexFile(file, content); if (!this.currentIndexingPromise) {
void this.vaultVectorStore?.indexFile(file, content);
}
}); });
} }
}) })
+4 -1
View File
@@ -8,7 +8,7 @@ http {
server_name localhost; server_name localhost;
location / { location / {
# CORS headers for every response (including 4xx/5xx from upstream) # CORS headers must be set for every response, including OPTIONS preflight
add_header 'Access-Control-Allow-Origin' '*' always; add_header 'Access-Control-Allow-Origin' '*' always;
add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, OPTIONS' always; add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, OPTIONS' always;
add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization' always; add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization' always;
@@ -16,6 +16,9 @@ http {
# Preflight OPTIONS # Preflight OPTIONS
if ($request_method = 'OPTIONS') { if ($request_method = 'OPTIONS') {
add_header 'Access-Control-Allow-Origin' '*' always;
add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, OPTIONS' always;
add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization' always;
add_header 'Access-Control-Max-Age' 1728000; add_header 'Access-Control-Max-Age' 1728000;
add_header 'Content-Type' 'text/plain; charset=utf-8'; add_header 'Content-Type' 'text/plain; charset=utf-8';
add_header 'Content-Length' 0; add_header 'Content-Length' 0;
+95 -19
View File
@@ -10,6 +10,8 @@ export default class OllamaPlugin extends Plugin {
settings: PluginSettings = DEFAULT_SETTINGS; settings: PluginSettings = DEFAULT_SETTINGS;
semanticCache?: SemanticCacheService; semanticCache?: SemanticCacheService;
vaultVectorStore?: VaultVectorStore; vaultVectorStore?: VaultVectorStore;
private indexingAbortController?: AbortController;
private currentIndexingPromise?: Promise<void>;
async onload() { async onload() {
await this.loadSettings(); await this.loadSettings();
@@ -92,6 +94,9 @@ export default class OllamaPlugin extends Plugin {
// eslint-disable-next-line @typescript-eslint/no-misused-promises // eslint-disable-next-line @typescript-eslint/no-misused-promises
onunload() { onunload() {
// Cancel any ongoing indexing
this.cancelBackgroundIndexing();
// Clean up any active semantic cache resources on plugin unload // Clean up any active semantic cache resources on plugin unload
// Using fire-and-forget pattern since onunload cannot be async per Obsidian API // Using fire-and-forget pattern since onunload cannot be async per Obsidian API
if (this.semanticCache) { if (this.semanticCache) {
@@ -110,6 +115,10 @@ export default class OllamaPlugin extends Plugin {
} }
async initializeVaultVectorStore(): Promise<void> { async initializeVaultVectorStore(): Promise<void> {
// Abort any ongoing indexing before re-initializing
this.cancelBackgroundIndexing();
await this.awaitBackgroundIndexing();
this.vaultVectorStore = new VaultVectorStore( this.vaultVectorStore = new VaultVectorStore(
this.settings.ollamaUrl, this.settings.ollamaUrl,
this.settings.vaultIndexConfig this.settings.vaultIndexConfig
@@ -117,44 +126,104 @@ export default class OllamaPlugin extends Plugin {
try { try {
await this.vaultVectorStore.initialize(); await this.vaultVectorStore.initialize();
// Run background indexing // Run background indexing
void this.backgroundIndexVault().catch((err) => { this.currentIndexingPromise = this.backgroundIndexVault();
Logger.warn(`Background vault indexing failed: ${String(err)}`, 'main'); void this.currentIndexingPromise
}); .catch((err) => {
Logger.warn(`Background vault indexing failed: ${String(err)}`, 'main');
})
.finally(() => {
this.currentIndexingPromise = undefined;
this.indexingAbortController = undefined;
});
} catch { } catch {
new Notice('Vault vector store initialization failed. Check console for details.'); new Notice('Vault vector store initialization failed. Check console for details.');
} }
} }
private cancelBackgroundIndexing(): void {
if (this.indexingAbortController) {
this.indexingAbortController.abort();
}
}
private async awaitBackgroundIndexing(): Promise<void> {
if (this.currentIndexingPromise) {
try {
await this.currentIndexingPromise;
} catch {
// ignore errors from cancelled indexing
}
}
}
async backgroundIndexVault(): Promise<void> { async backgroundIndexVault(): Promise<void> {
if (!this.vaultVectorStore) return; if (!this.vaultVectorStore) return;
this.indexingAbortController = new AbortController();
const signal = this.indexingAbortController.signal;
const files = this.app.vault.getMarkdownFiles(); const files = this.app.vault.getMarkdownFiles();
Logger.info(`Starting background vault indexing for ${files.length} files...`, 'main'); Logger.info(`Starting background vault indexing for ${files.length} files...`, 'main');
let indexed = 0; let indexed = 0;
for (const file of files) { const BATCH_SIZE = 5;
try { const DELAY_MS = 100;
const content = await this.app.vault.read(file);
await this.vaultVectorStore.indexFile(file, content); for (let i = 0; i < files.length; i += BATCH_SIZE) {
indexed++; if (signal.aborted) {
} catch (error) { Logger.info('Vault indexing cancelled.', 'main');
const errorMessage = error instanceof Error ? error.message : String(error); return;
Logger.warn(`Failed to index ${file.path}: ${errorMessage}`, 'main'); }
const batch = files.slice(i, i + BATCH_SIZE);
await Promise.all(
batch.map(async (file) => {
if (signal.aborted) return;
try {
const content = await this.app.vault.read(file);
if (signal.aborted) return;
await this.vaultVectorStore!.indexFile(file, content);
indexed++;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
Logger.warn(`Failed to index ${file.path}: ${errorMessage}`, 'main');
}
})
);
// Small delay between batches to avoid overloading Ollama
if (i + BATCH_SIZE < files.length) {
await new Promise((resolve) => setTimeout(resolve, DELAY_MS));
} }
} }
this.settings.lastIndexTime = Date.now(); if (!signal.aborted) {
await this.saveSettings(); this.settings.lastIndexTime = Date.now();
Logger.info(`Vault indexing complete: ${indexed}/${files.length} files indexed.`, 'main'); await this.saveSettings();
new Notice(`Vault index updated: ${indexed} files indexed.`); Logger.info(`Vault indexing complete: ${indexed}/${files.length} files indexed.`, 'main');
new Notice(`Vault index updated: ${indexed} files indexed.`);
}
} }
async rebuildVaultIndex(): Promise<void> { async rebuildVaultIndex(): Promise<void> {
// Cancel and await any ongoing indexing before clearing
this.cancelBackgroundIndexing();
await this.awaitBackgroundIndexing();
if (this.vaultVectorStore) { if (this.vaultVectorStore) {
await this.vaultVectorStore.clearIndex(); await this.vaultVectorStore.clearIndex();
await this.vaultVectorStore.initialize(); await this.vaultVectorStore.initialize();
} }
await this.backgroundIndexVault();
this.currentIndexingPromise = this.backgroundIndexVault();
void this.currentIndexingPromise
.catch((err) => {
Logger.warn(`Rebuild vault indexing failed: ${String(err)}`, 'main');
})
.finally(() => {
this.currentIndexingPromise = undefined;
this.indexingAbortController = undefined;
});
} }
async clearVaultIndex(): Promise<void> { async clearVaultIndex(): Promise<void> {
@@ -169,7 +238,10 @@ export default class OllamaPlugin extends Plugin {
this.app.vault.on('create', (file) => { this.app.vault.on('create', (file) => {
if (file instanceof TFile && file.extension === 'md' && this.vaultVectorStore) { if (file instanceof TFile && file.extension === 'md' && this.vaultVectorStore) {
void this.app.vault.read(file).then((content) => { void this.app.vault.read(file).then((content) => {
void this.vaultVectorStore?.indexFile(file, content); // Skip if a full rebuild is in progress to avoid race conditions
if (!this.currentIndexingPromise) {
void this.vaultVectorStore?.indexFile(file, content);
}
}); });
} }
}) })
@@ -180,7 +252,9 @@ export default class OllamaPlugin extends Plugin {
this.app.vault.on('modify', (file) => { this.app.vault.on('modify', (file) => {
if (file instanceof TFile && file.extension === 'md' && this.vaultVectorStore) { if (file instanceof TFile && file.extension === 'md' && this.vaultVectorStore) {
void this.app.vault.read(file).then((content) => { void this.app.vault.read(file).then((content) => {
void this.vaultVectorStore?.indexFile(file, content); if (!this.currentIndexingPromise) {
void this.vaultVectorStore?.indexFile(file, content);
}
}); });
} }
}) })
@@ -201,7 +275,9 @@ export default class OllamaPlugin extends Plugin {
if (file instanceof TFile && file.extension === 'md' && this.vaultVectorStore) { if (file instanceof TFile && file.extension === 'md' && this.vaultVectorStore) {
void this.vaultVectorStore.deleteFile(oldPath); void this.vaultVectorStore.deleteFile(oldPath);
void this.app.vault.read(file).then((content) => { void this.app.vault.read(file).then((content) => {
void this.vaultVectorStore?.indexFile(file, content); if (!this.currentIndexingPromise) {
void this.vaultVectorStore?.indexFile(file, content);
}
}); });
} }
}) })