@@ -3,7 +3,7 @@ import { createHash } from "crypto"
33
44import { QdrantVectorStore } from "../qdrant-client"
55import { 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
99vitest . 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+ / F a i l e d t o d e l e t e p o i n t s f o r 1 f i l e p a t h s a f t e r 3 r e t r i e s / ,
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+ / F a i l e d t o d e l e t e p o i n t s f o r 1 0 f i l e p a t h s a f t e r 3 r e t r i e s . C h u n k 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 ( / ^ \/ .* \/ s r c \/ t e s t .* \. t s $ / ) // 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