Skip to main content
Glama
orneryd

M.I.M.I.R - Multi-agent Intelligent Memory & Insight Repository

by orneryd
neo4j-retry-logic.test.ts11.2 kB
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { FileIndexer } from '../src/indexing/FileIndexer.js'; import type { Driver, Session } from 'neo4j-driver'; /** * Unit tests for Neo4j retry logic in FileIndexer * * Tests the retryNeo4jTransaction method's ability to handle: * - Deadlock errors (ForsetiClient conflicts) * - Lock timeout errors * - Transient errors (Neo.TransientError.*) * - Exponential backoff with jitter * - Non-retryable errors (fail immediately) */ describe('FileIndexer - Neo4j Retry Logic', () => { let mockDriver: Driver; let mockSession: Session; let fileIndexer: FileIndexer; let consoleWarnSpy: any; beforeEach(() => { // Mock Neo4j session mockSession = { run: vi.fn(), close: vi.fn().mockResolvedValue(undefined), } as unknown as Session; // Mock Neo4j driver mockDriver = { session: vi.fn().mockReturnValue(mockSession), } as unknown as Driver; // Create FileIndexer instance fileIndexer = new FileIndexer(mockDriver); // Spy on console.warn to verify retry messages consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); }); afterEach(() => { vi.restoreAllMocks(); }); describe('Deadlock Detection', () => { it('should retry on ForsetiClient deadlock errors', async () => { const deadlockError = new Error( "ForsetiClient[transactionId=177, clientId=1] can't acquire UpdateLock on NODE_RELATIONSHIP_GROUP_DELETE(40) " + "because holders of that lock are waiting for ForsetiClient[transactionId=177, clientId=1]" ); let attemptCount = 0; const mockOperation = vi.fn().mockImplementation(async () => { attemptCount++; if (attemptCount < 3) { throw deadlockError; } return { success: true }; }); // Access private method via any cast for testing const result = await (fileIndexer as any).retryNeo4jTransaction( mockOperation, 'Test operation', 3 ); expect(result).toEqual({ success: true }); expect(attemptCount).toBe(3); expect(consoleWarnSpy).toHaveBeenCalledTimes(2); // Failed on attempts 1 and 2 expect(consoleWarnSpy.mock.calls[0][0]).toContain('deadlock'); }); it('should retry on "can\'t acquire" lock errors', async () => { const lockError = new Error("can't acquire lock on resource"); lockError.code = 'Neo.TransientError.Transaction.DeadlockDetected'; let attemptCount = 0; const mockOperation = vi.fn().mockImplementation(async () => { attemptCount++; if (attemptCount < 2) { throw lockError; } return { success: true }; }); const result = await (fileIndexer as any).retryNeo4jTransaction( mockOperation, 'Test lock operation' ); expect(result).toEqual({ success: true }); expect(attemptCount).toBe(2); expect(consoleWarnSpy).toHaveBeenCalledTimes(1); }); }); describe('Lock Timeout Errors', () => { it('should retry on LockClient errors', async () => { const lockTimeoutError = new Error('LockClient stopped'); lockTimeoutError.code = 'Neo.TransientError.Transaction.LockClientStopped'; let attemptCount = 0; const mockOperation = vi.fn().mockImplementation(async () => { attemptCount++; if (attemptCount === 1) { throw lockTimeoutError; } return { success: true }; }); const result = await (fileIndexer as any).retryNeo4jTransaction( mockOperation, 'Test timeout operation' ); expect(result).toEqual({ success: true }); expect(attemptCount).toBe(2); expect(consoleWarnSpy.mock.calls[0][0]).toContain('lock timeout'); }); }); describe('Transient Errors', () => { it('should retry on generic Neo4j transient errors', async () => { const transientError = new Error('Database temporarily unavailable'); transientError.code = 'Neo.TransientError.Database.Unavailable'; let attemptCount = 0; const mockOperation = vi.fn().mockImplementation(async () => { attemptCount++; if (attemptCount === 1) { throw transientError; } return { success: true }; }); const result = await (fileIndexer as any).retryNeo4jTransaction( mockOperation, 'Test transient operation' ); expect(result).toEqual({ success: true }); expect(attemptCount).toBe(2); expect(consoleWarnSpy.mock.calls[0][0]).toContain('transient'); }); }); describe('Non-Retryable Errors', () => { it('should not retry on syntax errors', async () => { const syntaxError = new Error('Invalid Cypher syntax'); syntaxError.code = 'Neo.ClientError.Statement.SyntaxError'; const mockOperation = vi.fn().mockRejectedValue(syntaxError); await expect( (fileIndexer as any).retryNeo4jTransaction(mockOperation, 'Test syntax operation') ).rejects.toThrow('Invalid Cypher syntax'); expect(mockOperation).toHaveBeenCalledTimes(1); expect(consoleWarnSpy).not.toHaveBeenCalled(); }); it('should not retry on client errors', async () => { const clientError = new Error('Constraint violation'); clientError.code = 'Neo.ClientError.Schema.ConstraintValidationFailed'; const mockOperation = vi.fn().mockRejectedValue(clientError); await expect( (fileIndexer as any).retryNeo4jTransaction(mockOperation, 'Test constraint operation') ).rejects.toThrow('Constraint violation'); expect(mockOperation).toHaveBeenCalledTimes(1); expect(consoleWarnSpy).not.toHaveBeenCalled(); }); it('should fail after max retries on persistent deadlock', async () => { const deadlockError = new Error('ForsetiClient deadlock'); const mockOperation = vi.fn().mockRejectedValue(deadlockError); await expect( (fileIndexer as any).retryNeo4jTransaction(mockOperation, 'Test max retry', 3) ).rejects.toThrow('ForsetiClient deadlock'); expect(mockOperation).toHaveBeenCalledTimes(4); // Initial + 3 retries expect(consoleWarnSpy).toHaveBeenCalledTimes(3); }); }); describe('Exponential Backoff', () => { it('should implement exponential backoff with increasing delays', async () => { const deadlockError = new Error('ForsetiClient deadlock'); const delays: number[] = []; const startTimes: number[] = []; let attemptCount = 0; const mockOperation = vi.fn().mockImplementation(async () => { startTimes.push(Date.now()); attemptCount++; if (attemptCount < 4) { throw deadlockError; } return { success: true }; }); // Mock setTimeout to track delays const originalSetTimeout = global.setTimeout; vi.spyOn(global, 'setTimeout').mockImplementation(((fn: any, delay: number) => { delays.push(delay); return originalSetTimeout(fn, 0); // Execute immediately for test }) as any); await (fileIndexer as any).retryNeo4jTransaction(mockOperation, 'Test backoff', 3); expect(delays.length).toBe(3); // Verify exponential growth: each delay should be roughly double the previous // Allow for jitter (0-50ms) in comparisons expect(delays[0]).toBeGreaterThanOrEqual(100); // ~100ms expect(delays[0]).toBeLessThan(200); // 100 + 50 (jitter) expect(delays[1]).toBeGreaterThanOrEqual(200); // ~200ms expect(delays[1]).toBeLessThan(300); // 200 + 50 (jitter) expect(delays[2]).toBeGreaterThanOrEqual(400); // ~400ms expect(delays[2]).toBeLessThan(500); // 400 + 50 (jitter) }); it('should cap maximum delay at 2000ms', async () => { const deadlockError = new Error('ForsetiClient deadlock'); const delays: number[] = []; let attemptCount = 0; const mockOperation = vi.fn().mockImplementation(async () => { attemptCount++; if (attemptCount < 6) { throw deadlockError; } return { success: true }; }); // Save original setTimeout before mocking const originalSetTimeout = global.setTimeout; vi.spyOn(global, 'setTimeout').mockImplementation(((fn: any, delay: number) => { delays.push(delay); return originalSetTimeout(fn, 0); // Use original setTimeout to avoid recursion }) as any); await (fileIndexer as any).retryNeo4jTransaction(mockOperation, 'Test max delay', 5); // After several retries, delay should be capped at 2000ms const maxDelay = Math.max(...delays); expect(maxDelay).toBeLessThanOrEqual(2050); // 2000 + 50 (jitter) }); }); describe('Operation Context', () => { it('should include operation name in warning messages', async () => { const deadlockError = new Error('ForsetiClient deadlock'); const mockOperation = vi.fn() .mockRejectedValueOnce(deadlockError) .mockResolvedValue({ success: true }); await (fileIndexer as any).retryNeo4jTransaction( mockOperation, 'Index file /app/docs/README.md' ); expect(consoleWarnSpy).toHaveBeenCalledTimes(1); expect(consoleWarnSpy.mock.calls[0][0]).toContain('Index file /app/docs/README.md'); }); it('should preserve original error after exhausting retries', async () => { const originalError = new Error('Persistent deadlock with specific details'); originalError.code = 'Neo.TransientError.Transaction.DeadlockDetected'; const mockOperation = vi.fn().mockRejectedValue(originalError); try { await (fileIndexer as any).retryNeo4jTransaction(mockOperation, 'Test operation', 2); expect.fail('Should have thrown error'); } catch (error: any) { expect(error.message).toBe('Persistent deadlock with specific details'); expect(error.code).toBe('Neo.TransientError.Transaction.DeadlockDetected'); } }); }); describe('Success Cases', () => { it('should return result immediately on first success', async () => { const mockOperation = vi.fn().mockResolvedValue({ data: 'success' }); const result = await (fileIndexer as any).retryNeo4jTransaction( mockOperation, 'Test immediate success' ); expect(result).toEqual({ data: 'success' }); expect(mockOperation).toHaveBeenCalledTimes(1); expect(consoleWarnSpy).not.toHaveBeenCalled(); }); it('should return result after single retry', async () => { const deadlockError = new Error('ForsetiClient deadlock'); const mockOperation = vi.fn() .mockRejectedValueOnce(deadlockError) .mockResolvedValue({ data: 'success after retry' }); const result = await (fileIndexer as any).retryNeo4jTransaction( mockOperation, 'Test single retry' ); expect(result).toEqual({ data: 'success after retry' }); expect(mockOperation).toHaveBeenCalledTimes(2); expect(consoleWarnSpy).toHaveBeenCalledTimes(1); }); }); });

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/orneryd/Mimir'

If you have feedback or need assistance with the MCP directory API, please join our Discord server