Skip to content
This repository was archived by the owner on May 15, 2026. It is now read-only.

Commit 76baa43

Browse files
committed
fix: prevent unnecessary full reindex when Qdrant collection already exists
Root cause: getCollectionInfo() treated ALL errors (including connection failures and timeouts) as "collection not found", causing initialize() to create a new collection even when one already existed with valid data. Additionally, the error handler in the orchestrator aggressively cleared both the collection and cache on any indexing error, destroying existing indexed data. Changes: - getCollectionInfo(): Only return null for 404 (not found) errors; propagate other errors (connection failures, timeouts) so callers can distinguish "missing collection" from "unreachable Qdrant" - hasIndexedData(): Let connection errors propagate instead of silently returning false (which triggered full reindex) - collectionExists(): Same error propagation improvement - Orchestrator error handler: Only clear collection + cache when the collection was just created (no pre-existing data to preserve). For existing collections, flush/persist the cache instead of clearing it so incremental scans can resume on next startup. Fixes #12145
1 parent cb83656 commit 76baa43

4 files changed

Lines changed: 160 additions & 84 deletions

File tree

src/services/code-index/__tests__/orchestrator.spec.ts

Lines changed: 36 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -130,9 +130,9 @@ describe("CodeIndexOrchestrator - error path cleanup gating", () => {
130130
expect(lastCall[0]).toBe("Error")
131131
})
132132

133-
it("should call clearCollection() and clear cache when an error occurs after initialize() succeeds (indexing started)", async () => {
134-
// Arrange: initialize succeeds; fail soon after to enter error path with indexingStarted=true
135-
vectorStore.initialize.mockResolvedValue(false) // existing collection
133+
it("should preserve existing data when an error occurs on an existing collection (collectionCreated=false)", async () => {
134+
// Arrange: initialize succeeds with existing collection; fail soon after
135+
vectorStore.initialize.mockResolvedValue(false) // existing collection, NOT newly created
136136
vectorStore.hasIndexedData.mockResolvedValue(false) // force full scan path
137137
vectorStore.markIndexingIncomplete.mockRejectedValue(new Error("mark incomplete failure"))
138138

@@ -149,9 +149,40 @@ describe("CodeIndexOrchestrator - error path cleanup gating", () => {
149149
// Act
150150
await orchestrator.startIndexing()
151151

152-
// Assert: cleanup gated behind indexingStarted should have happened
152+
// Assert: should NOT clear existing collection data on error (preserves user's index)
153+
expect(vectorStore.clearCollection).not.toHaveBeenCalled()
154+
// Should flush (persist) cache rather than clearing it
155+
expect(cacheManager.flush).toHaveBeenCalledTimes(1)
156+
expect(cacheManager.clearCacheFile).not.toHaveBeenCalled()
157+
158+
// Error state should be set
159+
expect(stateManager.setSystemState).toHaveBeenCalled()
160+
const lastCall = stateManager.setSystemState.mock.calls[stateManager.setSystemState.mock.calls.length - 1]
161+
expect(lastCall[0]).toBe("Error")
162+
})
163+
164+
it("should clear collection and cache when an error occurs on a newly created collection (collectionCreated=true)", async () => {
165+
// Arrange: initialize creates a new collection; fail soon after
166+
vectorStore.initialize.mockResolvedValue(true) // newly created collection
167+
vectorStore.hasIndexedData.mockResolvedValue(false) // new collection has no data
168+
vectorStore.markIndexingIncomplete.mockRejectedValue(new Error("mark incomplete failure"))
169+
170+
const orchestrator = new CodeIndexOrchestrator(
171+
configManager,
172+
stateManager,
173+
workspacePath,
174+
cacheManager,
175+
vectorStore,
176+
scanner,
177+
fileWatcher,
178+
)
179+
180+
// Act
181+
await orchestrator.startIndexing()
182+
183+
// Assert: should clear data since the collection was just created (no pre-existing data to preserve)
153184
expect(vectorStore.clearCollection).toHaveBeenCalledTimes(1)
154-
expect(cacheManager.clearCacheFile).toHaveBeenCalledTimes(1)
185+
expect(cacheManager.clearCacheFile).toHaveBeenCalledTimes(2) // once in try block (collectionCreated), once in catch
155186

156187
// Error state should be set
157188
expect(stateManager.setSystemState).toHaveBeenCalled()

src/services/code-index/orchestrator.ts

Lines changed: 34 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -129,9 +129,12 @@ export class CodeIndexOrchestrator {
129129
// Track whether we successfully connected to Qdrant and started indexing
130130
// This helps us decide whether to preserve cache on error
131131
let indexingStarted = false
132+
// Track whether a new collection was created (vs reusing existing one)
133+
// This helps us decide whether to clear data on error
134+
let collectionCreated = false
132135

133136
try {
134-
const collectionCreated = await this.vectorStore.initialize()
137+
collectionCreated = await this.vectorStore.initialize()
135138

136139
// Successfully connected to Qdrant
137140
indexingStarted = true
@@ -316,27 +319,38 @@ export class CodeIndexOrchestrator {
316319
stack: error instanceof Error ? error.stack : undefined,
317320
location: "startIndexing",
318321
})
319-
if (indexingStarted) {
320-
try {
321-
await this.vectorStore.clearCollection()
322-
} catch (cleanupError) {
323-
console.error("[CodeIndexOrchestrator] Failed to clean up after error:", cleanupError)
324-
TelemetryService.instance.captureEvent(TelemetryEventName.CODE_INDEX_ERROR, {
325-
error: cleanupError instanceof Error ? cleanupError.message : String(cleanupError),
326-
stack: cleanupError instanceof Error ? cleanupError.stack : undefined,
327-
location: "startIndexing.cleanup",
328-
})
329-
}
330-
}
331322

332-
// Only clear cache if indexing had started (Qdrant connection succeeded)
333-
// If we never connected to Qdrant, preserve cache for incremental scan when it comes back
323+
// Determine if this is a connection error (never reached Qdrant) vs a mid-indexing failure.
324+
// Only wipe collection + cache when indexing actually started AND data was written,
325+
// since clearing on transient errors destroys a perfectly valid existing index.
334326
if (indexingStarted) {
335-
// Indexing started but failed mid-way - clear cache to avoid cache-Qdrant mismatch
336-
await this.cacheManager.clearCacheFile()
337-
console.log(
338-
"[CodeIndexOrchestrator] Indexing failed after starting. Clearing cache to avoid inconsistency.",
339-
)
327+
// Indexing started — but only clear data if a new collection was just created
328+
// (meaning there's no pre-existing data to preserve). If we were doing an
329+
// incremental scan on an existing collection, preserve the data so the user
330+
// doesn't lose their entire index due to a transient embedding API error.
331+
if (collectionCreated) {
332+
try {
333+
await this.vectorStore.clearCollection()
334+
} catch (cleanupError) {
335+
console.error("[CodeIndexOrchestrator] Failed to clean up after error:", cleanupError)
336+
TelemetryService.instance.captureEvent(TelemetryEventName.CODE_INDEX_ERROR, {
337+
error: cleanupError instanceof Error ? cleanupError.message : String(cleanupError),
338+
stack: cleanupError instanceof Error ? cleanupError.stack : undefined,
339+
location: "startIndexing.cleanup",
340+
})
341+
}
342+
await this.cacheManager.clearCacheFile()
343+
console.log(
344+
"[CodeIndexOrchestrator] Indexing failed on a newly created collection. Clearing cache to avoid inconsistency.",
345+
)
346+
} else {
347+
// Pre-existing collection — flush (persist) the cache rather than clearing it
348+
// so the next startup can resume incrementally from where we left off.
349+
await this.cacheManager.flush()
350+
console.log(
351+
"[CodeIndexOrchestrator] Indexing failed on existing collection. Preserving existing data and cache for recovery.",
352+
)
353+
}
340354
} else {
341355
// Never connected to Qdrant - preserve cache for future incremental scan
342356
console.log(

src/services/code-index/vector-store/__tests__/qdrant-client.spec.ts

Lines changed: 14 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -647,23 +647,21 @@ describe("QdrantVectorStore", () => {
647647
expect(mockQdrantClientInstance.createPayloadIndex).toHaveBeenCalledTimes(6)
648648
;(console.warn as any).mockRestore() // Restore console.warn
649649
})
650-
it("should log warning for non-404 errors but still create collection", async () => {
650+
it("should throw on non-404 errors instead of creating a new collection", async () => {
651651
const genericError = new Error("Generic Qdrant Error")
652652
mockQdrantClientInstance.getCollection.mockRejectedValue(genericError)
653-
vitest.spyOn(console, "warn").mockImplementation(() => {}) // Suppress console.warn
653+
vitest.spyOn(console, "error").mockImplementation(() => {}) // Suppress console.error
654654

655-
const result = await vectorStore.initialize()
655+
// Non-404 errors should propagate up as a connection failure, NOT create a new collection
656+
await expect(vectorStore.initialize()).rejects.toThrow(
657+
/Failed to connect to Qdrant vector database|vectorStore\.qdrantConnectionFailed/,
658+
)
656659

657-
expect(result).toBe(true) // Collection was created
658660
expect(mockQdrantClientInstance.getCollection).toHaveBeenCalledTimes(1)
659-
expect(mockQdrantClientInstance.createCollection).toHaveBeenCalledTimes(1)
661+
// Should NOT have tried to create a collection - the error is a connectivity issue, not a missing collection
662+
expect(mockQdrantClientInstance.createCollection).not.toHaveBeenCalled()
660663
expect(mockQdrantClientInstance.deleteCollection).not.toHaveBeenCalled()
661-
expect(mockQdrantClientInstance.createPayloadIndex).toHaveBeenCalledTimes(6)
662-
expect(console.warn).toHaveBeenCalledWith(
663-
expect.stringContaining(`Warning during getCollectionInfo for "${expectedCollectionName}"`),
664-
genericError.message,
665-
)
666-
;(console.warn as any).mockRestore()
664+
;(console.error as any).mockRestore()
667665
})
668666
it("should re-throw error from createCollection when no collection initially exists", async () => {
669667
mockQdrantClientInstance.getCollection.mockRejectedValue({
@@ -1007,20 +1005,16 @@ describe("QdrantVectorStore", () => {
10071005
expect(mockQdrantClientInstance.getCollection).toHaveBeenCalledWith(expectedCollectionName)
10081006
})
10091007

1010-
it("should return false and log warning for non-404 errors", async () => {
1008+
it("should throw for non-404 errors instead of returning false", async () => {
10111009
const genericError = new Error("Network error")
10121010
mockQdrantClientInstance.getCollection.mockRejectedValue(genericError)
1013-
vitest.spyOn(console, "warn").mockImplementation(() => {})
1011+
vitest.spyOn(console, "error").mockImplementation(() => {})
10141012

1015-
const result = await vectorStore.collectionExists()
1013+
// Non-404 errors should propagate so callers know Qdrant is unreachable
1014+
await expect(vectorStore.collectionExists()).rejects.toThrow("Network error")
10161015

1017-
expect(result).toBe(false)
10181016
expect(mockQdrantClientInstance.getCollection).toHaveBeenCalledTimes(1)
1019-
expect(console.warn).toHaveBeenCalledWith(
1020-
expect.stringContaining(`Warning during getCollectionInfo for "${expectedCollectionName}"`),
1021-
genericError.message,
1022-
)
1023-
;(console.warn as any).mockRestore()
1017+
;(console.error as any).mockRestore()
10241018
})
10251019
describe("deleteCollection", () => {
10261020
it("should delete collection when it exists", async () => {

src/services/code-index/vector-store/qdrant-client.ts

Lines changed: 76 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -127,18 +127,56 @@ export class QdrantVectorStore implements IVectorStore {
127127
}
128128
}
129129

130+
/**
131+
* Checks if an error from the Qdrant client indicates a "not found" (404) response.
132+
* Qdrant client errors may have a `response.status` property or a `status` property.
133+
*/
134+
private isNotFoundError(error: unknown): boolean {
135+
if (error && typeof error === "object") {
136+
const err = error as Record<string, any>
137+
// Check for response.status (common Qdrant client error shape)
138+
if (err.response?.status === 404) {
139+
return true
140+
}
141+
// Check for top-level status property
142+
if (err.status === 404) {
143+
return true
144+
}
145+
// Check for error message patterns indicating not found
146+
if (err.message && typeof err.message === "string") {
147+
const msg = err.message.toLowerCase()
148+
if (msg.includes("not found") || msg.includes("doesn't exist") || msg.includes("does not exist")) {
149+
return true
150+
}
151+
}
152+
}
153+
return false
154+
}
155+
156+
/**
157+
* Retrieves collection info from Qdrant.
158+
* Returns null ONLY when the collection does not exist (404).
159+
* Throws for other errors (connection failures, timeouts, etc.) so callers
160+
* can distinguish "collection missing" from "Qdrant unreachable".
161+
*/
130162
private async getCollectionInfo(): Promise<Schemas["CollectionInfo"] | null> {
131163
try {
132164
const collectionInfo = await this.client.getCollection(this.collectionName)
133165
return collectionInfo
134166
} catch (error: unknown) {
135-
if (error instanceof Error) {
136-
console.warn(
137-
`[QdrantVectorStore] Warning during getCollectionInfo for "${this.collectionName}". Collection may not exist or another error occurred:`,
138-
error.message,
167+
if (this.isNotFoundError(error)) {
168+
console.log(
169+
`[QdrantVectorStore] Collection "${this.collectionName}" not found (404). Will create a new one.`,
139170
)
171+
return null
140172
}
141-
return null
173+
// For non-404 errors (connection failures, timeouts, etc.), propagate the error
174+
// so callers don't mistakenly assume the collection doesn't exist.
175+
const message = error instanceof Error ? error.message : String(error)
176+
console.error(
177+
`[QdrantVectorStore] Error retrieving collection "${this.collectionName}" (not a 404): ${message}`,
178+
)
179+
throw error
142180
}
143181
}
144182

@@ -572,52 +610,51 @@ export class QdrantVectorStore implements IVectorStore {
572610
}
573611

574612
/**
575-
* Checks if the collection exists
576-
* @returns Promise resolving to boolean indicating if the collection exists
613+
* Checks if the collection exists.
614+
* Returns false for 404 (not found). Throws for connection/other errors.
577615
*/
578616
async collectionExists(): Promise<boolean> {
579617
const collectionInfo = await this.getCollectionInfo()
580618
return collectionInfo !== null
581619
}
582620

583621
/**
584-
* Checks if the collection exists and has indexed points
585-
* @returns Promise resolving to boolean indicating if the collection exists and has points
622+
* Checks if the collection exists and has indexed points.
623+
* Returns false when the collection doesn't exist (404).
624+
* Throws for connection errors so callers can distinguish
625+
* "no data" from "can't reach Qdrant".
586626
*/
587627
async hasIndexedData(): Promise<boolean> {
588-
try {
589-
const collectionInfo = await this.getCollectionInfo()
590-
if (!collectionInfo) {
591-
return false
592-
}
593-
// Check if the collection has any points indexed
594-
const pointsCount = collectionInfo.points_count ?? 0
595-
if (pointsCount === 0) {
596-
return false
597-
}
598-
599-
// Check if the indexing completion marker exists
600-
// Use a deterministic UUID generated from a constant string
601-
const metadataId = uuidv5("__indexing_metadata__", QDRANT_CODE_BLOCK_NAMESPACE)
602-
const metadataPoints = await this.client.retrieve(this.collectionName, {
603-
ids: [metadataId],
604-
})
628+
// getCollectionInfo() now throws on non-404 errors, so connection
629+
// failures will propagate to the caller instead of returning false.
630+
const collectionInfo = await this.getCollectionInfo()
631+
if (!collectionInfo) {
632+
return false
633+
}
634+
// Check if the collection has any points indexed
635+
const pointsCount = collectionInfo.points_count ?? 0
636+
if (pointsCount === 0) {
637+
return false
638+
}
605639

606-
// If marker exists, use it to determine completion status
607-
if (metadataPoints.length > 0) {
608-
return metadataPoints[0].payload?.indexing_complete === true
609-
}
640+
// Check if the indexing completion marker exists
641+
// Use a deterministic UUID generated from a constant string
642+
const metadataId = uuidv5("__indexing_metadata__", QDRANT_CODE_BLOCK_NAMESPACE)
643+
const metadataPoints = await this.client.retrieve(this.collectionName, {
644+
ids: [metadataId],
645+
})
610646

611-
// Backward compatibility: No marker exists (old index or pre-marker version)
612-
// Fall back to old logic - assume complete if collection has points
613-
console.log(
614-
"[QdrantVectorStore] No indexing metadata marker found. Using backward compatibility mode (checking points_count > 0).",
615-
)
616-
return pointsCount > 0
617-
} catch (error) {
618-
console.warn("[QdrantVectorStore] Failed to check if collection has data:", error)
619-
return false
647+
// If marker exists, use it to determine completion status
648+
if (metadataPoints.length > 0) {
649+
return metadataPoints[0].payload?.indexing_complete === true
620650
}
651+
652+
// Backward compatibility: No marker exists (old index or pre-marker version)
653+
// Fall back to old logic - assume complete if collection has points
654+
console.log(
655+
"[QdrantVectorStore] No indexing metadata marker found. Using backward compatibility mode (checking points_count > 0).",
656+
)
657+
return pointsCount > 0
621658
}
622659

623660
/**

0 commit comments

Comments
 (0)