Skip to content

Commit b8bc306

Browse files
committed
fix: improve deletePointsByMultipleFilePaths error handling to prevent VS Code crashes
- Add request size limits (MAX_DELETE_PATHS_PER_REQUEST = 100) to prevent oversized requests - Implement retry logic with exponential backoff for failed deletion operations - Add comprehensive error handling with contextual error messages - Include telemetry tracking for monitoring deletion failures - Add chunking support to handle large file path arrays - Prevent crash loops by gracefully handling Qdrant API failures - Add comprehensive test coverage for new error handling and retry logic Fixes #5857
1 parent a28d50e commit b8bc306

File tree

3 files changed

+358
-23
lines changed

3 files changed

+358
-23
lines changed

src/services/code-index/constants/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,9 @@ export const MAX_BATCH_RETRIES = 3
2121
export const INITIAL_RETRY_DELAY_MS = 500
2222
export const PARSING_CONCURRENCY = 10
2323

24+
/**Vector Store */
25+
export const MAX_DELETE_PATHS_PER_REQUEST = 100 // Maximum number of file paths to delete in a single request
26+
2427
/**OpenAI Embedder */
2528
export const MAX_BATCH_TOKENS = 100000
2629
export const MAX_ITEM_TOKENS = 8191

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

Lines changed: 267 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { createHash } from "crypto"
33

44
import { QdrantVectorStore } from "../qdrant-client"
55
import { getWorkspacePath } from "../../../../utils/path"
6-
import { DEFAULT_MAX_SEARCH_RESULTS, DEFAULT_SEARCH_MIN_SCORE } from "../../constants"
6+
import { DEFAULT_MAX_SEARCH_RESULTS, DEFAULT_SEARCH_MIN_SCORE, MAX_DELETE_PATHS_PER_REQUEST, MAX_BATCH_RETRIES, INITIAL_RETRY_DELAY_MS } from "../../constants"
77

88
// Mocks
99
vitest.mock("@qdrant/js-client-rest")
@@ -1527,4 +1527,270 @@ describe("QdrantVectorStore", () => {
15271527
expect(callArgs.score_threshold).toBe(DEFAULT_SEARCH_MIN_SCORE)
15281528
})
15291529
})
1530+
1531+
describe("deletePointsByMultipleFilePaths", () => {
1532+
beforeEach(() => {
1533+
// Mock TelemetryService
1534+
vitest.mock("@roo-code/telemetry", () => ({
1535+
TelemetryService: {
1536+
instance: {
1537+
captureEvent: vitest.fn(),
1538+
},
1539+
},
1540+
}))
1541+
vitest.mock("@roo-code/types", () => ({
1542+
TelemetryEventName: {
1543+
CODE_INDEX_ERROR: "code_index_error",
1544+
},
1545+
}))
1546+
vitest.mock("../shared/validation-helpers", () => ({
1547+
sanitizeErrorMessage: vitest.fn((msg) => msg),
1548+
}))
1549+
})
1550+
1551+
it("should handle empty file paths array", async () => {
1552+
await vectorStore.deletePointsByMultipleFilePaths([])
1553+
1554+
expect(mockQdrantClientInstance.delete).not.toHaveBeenCalled()
1555+
})
1556+
1557+
it("should successfully delete points for single file path", async () => {
1558+
const filePaths = ["src/test.ts"]
1559+
mockQdrantClientInstance.delete.mockResolvedValue({} as any)
1560+
1561+
await vectorStore.deletePointsByMultipleFilePaths(filePaths)
1562+
1563+
expect(mockQdrantClientInstance.delete).toHaveBeenCalledTimes(1)
1564+
expect(mockQdrantClientInstance.delete).toHaveBeenCalledWith(expectedCollectionName, {
1565+
filter: {
1566+
should: [
1567+
{
1568+
key: "filePath",
1569+
match: {
1570+
value: expect.stringContaining("src/test.ts"),
1571+
},
1572+
},
1573+
],
1574+
},
1575+
wait: true,
1576+
})
1577+
})
1578+
1579+
it("should successfully delete points for multiple file paths within chunk limit", async () => {
1580+
const filePaths = ["src/test1.ts", "src/test2.ts", "src/test3.ts"]
1581+
mockQdrantClientInstance.delete.mockResolvedValue({} as any)
1582+
1583+
await vectorStore.deletePointsByMultipleFilePaths(filePaths)
1584+
1585+
expect(mockQdrantClientInstance.delete).toHaveBeenCalledTimes(1)
1586+
expect(mockQdrantClientInstance.delete).toHaveBeenCalledWith(expectedCollectionName, {
1587+
filter: {
1588+
should: expect.arrayContaining([
1589+
{
1590+
key: "filePath",
1591+
match: {
1592+
value: expect.stringContaining("src/test1.ts"),
1593+
},
1594+
},
1595+
{
1596+
key: "filePath",
1597+
match: {
1598+
value: expect.stringContaining("src/test2.ts"),
1599+
},
1600+
},
1601+
{
1602+
key: "filePath",
1603+
match: {
1604+
value: expect.stringContaining("src/test3.ts"),
1605+
},
1606+
},
1607+
]),
1608+
},
1609+
wait: true,
1610+
})
1611+
})
1612+
1613+
it("should chunk large file path arrays into multiple requests", async () => {
1614+
// Create an array larger than MAX_DELETE_PATHS_PER_REQUEST
1615+
const filePaths = Array.from({ length: MAX_DELETE_PATHS_PER_REQUEST + 50 }, (_, i) => `src/test${i}.ts`)
1616+
mockQdrantClientInstance.delete.mockResolvedValue({} as any)
1617+
1618+
await vectorStore.deletePointsByMultipleFilePaths(filePaths)
1619+
1620+
// Should be called twice: once for the first chunk, once for the remainder
1621+
expect(mockQdrantClientInstance.delete).toHaveBeenCalledTimes(2)
1622+
1623+
// First call should have MAX_DELETE_PATHS_PER_REQUEST items
1624+
const firstCall = mockQdrantClientInstance.delete.mock.calls[0][1]
1625+
expect(firstCall.filter.should).toHaveLength(MAX_DELETE_PATHS_PER_REQUEST)
1626+
1627+
// Second call should have the remaining 50 items
1628+
const secondCall = mockQdrantClientInstance.delete.mock.calls[1][1]
1629+
expect(secondCall.filter.should).toHaveLength(50)
1630+
})
1631+
1632+
it("should retry on failure and eventually succeed", async () => {
1633+
const filePaths = ["src/test.ts"]
1634+
const deleteError = new Error("Temporary Qdrant error")
1635+
1636+
// Fail twice, then succeed
1637+
mockQdrantClientInstance.delete
1638+
.mockRejectedValueOnce(deleteError)
1639+
.mockRejectedValueOnce(deleteError)
1640+
.mockResolvedValueOnce({} as any)
1641+
1642+
vitest.spyOn(console, "warn").mockImplementation(() => {})
1643+
1644+
await vectorStore.deletePointsByMultipleFilePaths(filePaths)
1645+
1646+
expect(mockQdrantClientInstance.delete).toHaveBeenCalledTimes(3)
1647+
expect(console.warn).toHaveBeenCalledTimes(2) // Two warnings for the failed attempts
1648+
;(console.warn as any).mockRestore()
1649+
})
1650+
1651+
it("should throw error after exhausting all retries", async () => {
1652+
const filePaths = ["src/test.ts"]
1653+
const deleteError = new Error("Persistent Qdrant error")
1654+
1655+
// Fail all retry attempts
1656+
mockQdrantClientInstance.delete.mockRejectedValue(deleteError)
1657+
1658+
vitest.spyOn(console, "warn").mockImplementation(() => {})
1659+
vitest.spyOn(console, "error").mockImplementation(() => {})
1660+
1661+
await expect(vectorStore.deletePointsByMultipleFilePaths(filePaths)).rejects.toThrow(
1662+
/Failed to delete points for 1 file paths after 3 retries/,
1663+
)
1664+
1665+
expect(mockQdrantClientInstance.delete).toHaveBeenCalledTimes(MAX_BATCH_RETRIES)
1666+
expect(console.warn).toHaveBeenCalledTimes(MAX_BATCH_RETRIES)
1667+
expect(console.error).toHaveBeenCalledTimes(1)
1668+
;(console.warn as any).mockRestore()
1669+
;(console.error as any).mockRestore()
1670+
})
1671+
1672+
it("should handle mixed success and failure across chunks", async () => {
1673+
// Create an array that will be split into 2 chunks
1674+
const filePaths = Array.from({ length: MAX_DELETE_PATHS_PER_REQUEST + 10 }, (_, i) => `src/test${i}.ts`)
1675+
const deleteError = new Error("Chunk 2 error")
1676+
1677+
// First chunk succeeds, second chunk fails
1678+
mockQdrantClientInstance.delete
1679+
.mockResolvedValueOnce({} as any) // First chunk succeeds
1680+
.mockRejectedValue(deleteError) // Second chunk fails all retries
1681+
1682+
vitest.spyOn(console, "warn").mockImplementation(() => {})
1683+
vitest.spyOn(console, "error").mockImplementation(() => {})
1684+
1685+
await expect(vectorStore.deletePointsByMultipleFilePaths(filePaths)).rejects.toThrow(
1686+
/Failed to delete points for 10 file paths after 3 retries. Chunk 2\/2/,
1687+
)
1688+
1689+
// First chunk: 1 call, Second chunk: MAX_BATCH_RETRIES calls
1690+
expect(mockQdrantClientInstance.delete).toHaveBeenCalledTimes(1 + MAX_BATCH_RETRIES)
1691+
;(console.warn as any).mockRestore()
1692+
;(console.error as any).mockRestore()
1693+
})
1694+
1695+
it("should use exponential backoff for retries", async () => {
1696+
const filePaths = ["src/test.ts"]
1697+
const deleteError = new Error("Temporary error")
1698+
1699+
// Fail twice, then succeed
1700+
mockQdrantClientInstance.delete
1701+
.mockRejectedValueOnce(deleteError)
1702+
.mockRejectedValueOnce(deleteError)
1703+
.mockResolvedValueOnce({} as any)
1704+
1705+
vitest.spyOn(console, "warn").mockImplementation(() => {})
1706+
const setTimeoutSpy = vitest.spyOn(global, "setTimeout").mockImplementation((fn: any) => {
1707+
fn() // Execute immediately for testing
1708+
return {} as any
1709+
})
1710+
1711+
await vectorStore.deletePointsByMultipleFilePaths(filePaths)
1712+
1713+
// Should have called setTimeout twice for the two retries
1714+
expect(setTimeoutSpy).toHaveBeenCalledTimes(2)
1715+
1716+
// First retry: INITIAL_RETRY_DELAY_MS * 2^0 = 500ms
1717+
expect(setTimeoutSpy).toHaveBeenNthCalledWith(1, expect.any(Function), INITIAL_RETRY_DELAY_MS)
1718+
1719+
// Second retry: INITIAL_RETRY_DELAY_MS * 2^1 = 1000ms
1720+
expect(setTimeoutSpy).toHaveBeenNthCalledWith(2, expect.any(Function), INITIAL_RETRY_DELAY_MS * 2)
1721+
1722+
setTimeoutSpy.mockRestore()
1723+
;(console.warn as any).mockRestore()
1724+
})
1725+
1726+
it("should normalize file paths correctly", async () => {
1727+
const filePaths = ["./src/test.ts", "src/../src/test2.ts"]
1728+
mockQdrantClientInstance.delete.mockResolvedValue({} as any)
1729+
1730+
await vectorStore.deletePointsByMultipleFilePaths(filePaths)
1731+
1732+
expect(mockQdrantClientInstance.delete).toHaveBeenCalledTimes(1)
1733+
const deleteCall = mockQdrantClientInstance.delete.mock.calls[0][1]
1734+
1735+
// Both paths should be normalized to absolute paths
1736+
expect(deleteCall.filter.should).toHaveLength(2)
1737+
deleteCall.filter.should.forEach((filter: any) => {
1738+
expect(filter.match.value).toMatch(/^\/.*\/src\/test.*\.ts$/) // Should be absolute paths
1739+
})
1740+
})
1741+
1742+
it("should capture telemetry events for errors", async () => {
1743+
const { TelemetryService } = await import("@roo-code/telemetry")
1744+
const { TelemetryEventName } = await import("@roo-code/types")
1745+
1746+
const filePaths = ["src/test.ts"]
1747+
const deleteError = new Error("Telemetry test error")
1748+
1749+
mockQdrantClientInstance.delete.mockRejectedValue(deleteError)
1750+
vitest.spyOn(console, "warn").mockImplementation(() => {})
1751+
vitest.spyOn(console, "error").mockImplementation(() => {})
1752+
1753+
await expect(vectorStore.deletePointsByMultipleFilePaths(filePaths)).rejects.toThrow()
1754+
1755+
// Should capture telemetry for each retry attempt plus final failure
1756+
expect(TelemetryService.instance.captureEvent).toHaveBeenCalledWith(
1757+
TelemetryEventName.CODE_INDEX_ERROR,
1758+
expect.objectContaining({
1759+
error: "Telemetry test error",
1760+
location: "deletePointsByMultipleFilePaths",
1761+
errorType: "deletion_error",
1762+
retryCount: expect.any(Number),
1763+
chunkIndex: 0,
1764+
chunkSize: 1,
1765+
totalChunks: 1,
1766+
}),
1767+
)
1768+
1769+
// Should also capture final retry exhausted event
1770+
expect(TelemetryService.instance.captureEvent).toHaveBeenCalledWith(
1771+
TelemetryEventName.CODE_INDEX_ERROR,
1772+
expect.objectContaining({
1773+
errorType: "deletion_retry_exhausted",
1774+
retryCount: MAX_BATCH_RETRIES,
1775+
}),
1776+
)
1777+
1778+
;(console.warn as any).mockRestore()
1779+
;(console.error as any).mockRestore()
1780+
})
1781+
})
1782+
1783+
describe("deletePointsByFilePath", () => {
1784+
it("should delegate to deletePointsByMultipleFilePaths", async () => {
1785+
const filePath = "src/test.ts"
1786+
const deleteMultipleSpy = vitest.spyOn(vectorStore, "deletePointsByMultipleFilePaths").mockResolvedValue()
1787+
1788+
await vectorStore.deletePointsByFilePath(filePath)
1789+
1790+
expect(deleteMultipleSpy).toHaveBeenCalledTimes(1)
1791+
expect(deleteMultipleSpy).toHaveBeenCalledWith([filePath])
1792+
1793+
deleteMultipleSpy.mockRestore()
1794+
})
1795+
})
15301796
})

0 commit comments

Comments
 (0)